From a4e623b2e630109e4b8708d3e7a261af821c4b86 Mon Sep 17 00:00:00 2001 From: Vedran Hrabar Date: Sat, 25 Jul 2026 16:51:56 +0200 Subject: [PATCH 01/20] feat(IssueHub): update tool window title to reflect source label #13 Signed-off-by: Vedran Hrabar --- .../issuehub/toolWindow/IssueHubToolWindowFactory.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueHubToolWindowFactory.kt b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueHubToolWindowFactory.kt index 506fdf6..73678dc 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueHubToolWindowFactory.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueHubToolWindowFactory.kt @@ -42,7 +42,15 @@ class IssueHubToolWindowFactory : ToolWindowFactory { ) { val panel = IssueHubToolWindowPanel(project) toolWindow.component.putClientProperty(ToolWindowContentUi.HIDE_ID_LABEL, "true") - val content = ContentFactory.getInstance().createContent(panel, IssueHubBundle["toolWindow.title"], false) + + val source = IssueProvider.firstApplicable(project)?.sourceLabel(project) + val content = + ContentFactory.getInstance().createContent( + panel, + source?.substringAfterLast('/') ?: IssueHubBundle["toolWindow.title"], + false, + ) + content.description = source toolWindow.contentManager.addContent(content) } From f1768a0acb20b58d0a1791dff66295a8252bb139 Mon Sep 17 00:00:00 2001 From: Vedran Hrabar Date: Sat, 25 Jul 2026 17:07:01 +0200 Subject: [PATCH 02/20] feat(Assets): add light and dark mode banners for IssueHub Signed-off-by: Vedran Hrabar --- README.md | 5 ++++ assets/issuehub-banner-dark.svg | 49 ++++++++++++++++++++++++++++++++ assets/issuehub-banner-light.svg | 49 ++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 assets/issuehub-banner-dark.svg create mode 100644 assets/issuehub-banner-light.svg diff --git a/README.md b/README.md index ab8ae0a..f297b61 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ + +![Logo (light)](assets/issuehub-banner-light.svg#gh-light-mode-only) + + +![Logo (dark)](assets/issuehub-banner-dark.svg#gh-dark-mode-only) # IssueHub ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/vhrabar/IssueHub/build.yml?style=for-the-badge) diff --git a/assets/issuehub-banner-dark.svg b/assets/issuehub-banner-dark.svg new file mode 100644 index 0000000..0c5fffa --- /dev/null +++ b/assets/issuehub-banner-dark.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + IssueHub + Where every issue finds its hub, right inside your IDE. + + \ No newline at end of file diff --git a/assets/issuehub-banner-light.svg b/assets/issuehub-banner-light.svg new file mode 100644 index 0000000..4ce6b1f --- /dev/null +++ b/assets/issuehub-banner-light.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + IssueHub + Where every issue finds its hub, right inside your IDE. + + \ No newline at end of file From 2ce9e2e2060d89086109128de033259759cb3a05 Mon Sep 17 00:00:00 2001 From: Vedran Hrabar Date: Tue, 28 Jul 2026 12:27:25 +0200 Subject: [PATCH 03/20] feat(Q/F): add milestone support and implement issue query filters #17 Signed-off-by: Vedran Hrabar --- .../github/vhrabar/issuehub/model/Issue.kt | 17 ++-- .../vhrabar/issuehub/model/IssueQuery.kt | 89 +++++++++++++++++++ 2 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 src/main/kotlin/com/github/vhrabar/issuehub/model/IssueQuery.kt diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/model/Issue.kt b/src/main/kotlin/com/github/vhrabar/issuehub/model/Issue.kt index ac381c0..b679a53 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/model/Issue.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/model/Issue.kt @@ -8,6 +8,16 @@ data class IssueLabel( val color: String? = null, ) +/** + * A milestone an issue can belong to. + * + * [number] is the provider's own identifier + */ +data class IssueMilestone( + val number: Int, + val title: String, +) + /** Provider-neutral repr. of tracked issues */ data class Issue( val id: Int, @@ -18,6 +28,7 @@ data class Issue( val labels: List = emptyList(), val assignee: String? = null, val assigneeAvatarUrl: String? = null, + val milestone: IssueMilestone? = null, val author: String? = null, val authorAvatarUrl: String? = null, val commentCount: Int = 0, @@ -25,9 +36,3 @@ data class Issue( val createdAt: String, val updatedAt: String, ) - -// placeholder for queries (50 last opened) -data class IssueQuery( - val state: IssueState = IssueState.OPEN, - val limit: Int = 50, -) diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/model/IssueQuery.kt b/src/main/kotlin/com/github/vhrabar/issuehub/model/IssueQuery.kt new file mode 100644 index 0000000..2cdd55b --- /dev/null +++ b/src/main/kotlin/com/github/vhrabar/issuehub/model/IssueQuery.kt @@ -0,0 +1,89 @@ +package com.github.vhrabar.issuehub.model + +/** Which issue states a query should return. */ +enum class IssueStateFilter { OPEN, CLOSED, ALL } + +/** The field a result list is ordered by. */ +enum class IssueSortField { CREATED, UPDATED, COMMENTS } + +enum class IssueSortDirection { ASC, DESC } + +/** + * Assignee restriction. + */ +sealed interface AssigneeFilter { + data object Unassigned : AssigneeFilter + + data class User( + val login: String, + ) : AssigneeFilter +} + +/** Milestone restriction; */ +sealed interface MilestoneFilter { + data object None : MilestoneFilter + + data class Named( + val milestone: IssueMilestone, + ) : MilestoneFilter +} + +/** + * A search/filter/sort request against a provider. + * + */ +data class IssueQuery( + val state: IssueStateFilter = IssueStateFilter.OPEN, + val text: String = "", + val labels: Set = emptySet(), + val assignee: AssigneeFilter? = null, + val author: String? = null, + val milestone: MilestoneFilter? = null, + val sortField: IssueSortField = IssueSortField.CREATED, + val sortDirection: IssueSortDirection = IssueSortDirection.DESC, + val limit: Int = 50, +) { + /** True when the query narrows the result set beyond the default "all open issues" view. */ + val isFiltered: Boolean + get() = + state != IssueStateFilter.OPEN || + text.isNotBlank() || + labels.isNotEmpty() || + assignee != null || + author != null || + milestone != null +} + +/** + * The values a user can pick from in the filter UI, as reported by the provider. + * + * Any list may be empty: providers that cannot enumerate a dimension (or whose token lacks the + * permission to) simply offer no choices for it rather than failing the whole refresh. + */ +data class IssueFilterOptions( + val labels: List = emptyList(), + val assignees: List = emptyList(), + val milestones: List = emptyList(), + val authors: List = emptyList(), +) { + /** Union with [other], de-duplicated and alphabetically ordered. */ + fun mergedWith(other: IssueFilterOptions): IssueFilterOptions = + IssueFilterOptions( + labels = (labels + other.labels).distinctBy { it.name }.sortedBy { it.name.lowercase() }, + assignees = (assignees + other.assignees).distinct().sortedBy(String::lowercase), + milestones = (milestones + other.milestones).distinctBy { it.number }.sortedBy { it.title.lowercase() }, + authors = (authors + other.authors).distinct().sortedBy(String::lowercase), + ) +} + +/** + * The filter values visible on an already-loaded page of issues. + * + */ +fun optionsFrom(issues: List): IssueFilterOptions = + IssueFilterOptions( + labels = issues.flatMap { it.labels }, + assignees = issues.mapNotNull { it.assignee }, + milestones = issues.mapNotNull { it.milestone }, + authors = issues.mapNotNull { it.author }, + ).mergedWith(IssueFilterOptions()) From 68ee9f2edcb0a9413fad54bc39e5046a3a8c077d Mon Sep 17 00:00:00 2001 From: Vedran Hrabar Date: Tue, 28 Jul 2026 12:29:19 +0200 Subject: [PATCH 04/20] feat(IssueProvider): add fetchFilterOptions method for filter UI enumeration #17 Signed-off-by: Vedran Hrabar --- .../com/github/vhrabar/issuehub/provider/IssueProvider.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/provider/IssueProvider.kt b/src/main/kotlin/com/github/vhrabar/issuehub/provider/IssueProvider.kt index 6749c8b..62d3b5a 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/provider/IssueProvider.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/provider/IssueProvider.kt @@ -1,6 +1,7 @@ package com.github.vhrabar.issuehub.provider import com.github.vhrabar.issuehub.model.Issue +import com.github.vhrabar.issuehub.model.IssueFilterOptions import com.github.vhrabar.issuehub.model.IssueQuery import com.intellij.openapi.extensions.ExtensionPointName import com.intellij.openapi.project.Project @@ -21,6 +22,9 @@ interface IssueProvider { query: IssueQuery, ): List + /** values the filter UI can offer; empty when the provider can't enumerate them */ + suspend fun fetchFilterOptions(project: Project): IssueFilterOptions = IssueFilterOptions() + companion object { val EP_NAME: ExtensionPointName = ExtensionPointName.create("com.github.vhrabar.issuehub.issueProvider") From 0dc86f38bb54fa98672adea0c9729886a512cbb8 Mon Sep 17 00:00:00 2001 From: Vedran Hrabar Date: Tue, 28 Jul 2026 13:41:36 +0200 Subject: [PATCH 05/20] feat(GitHubClient): implement issue fetching and filtering methods with support for labels, milestones, and assignees #17 Signed-off-by: Vedran Hrabar --- .../issuehub/provider/github/GitHubClient.kt | 162 +++++++++++++++++- .../issuehub/provider/github/GitHubDto.kt | 13 ++ .../provider/github/GitHubIssueProvider.kt | 33 +++- 3 files changed, 192 insertions(+), 16 deletions(-) diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubClient.kt b/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubClient.kt index af37a6e..ff8d392 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubClient.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubClient.kt @@ -1,13 +1,22 @@ package com.github.vhrabar.issuehub.provider.github +import com.github.vhrabar.issuehub.model.AssigneeFilter +import com.github.vhrabar.issuehub.model.IssueQuery +import com.github.vhrabar.issuehub.model.IssueSortDirection +import com.github.vhrabar.issuehub.model.IssueSortField +import com.github.vhrabar.issuehub.model.IssueStateFilter +import com.github.vhrabar.issuehub.model.MilestoneFilter import com.intellij.openapi.diagnostic.thisLogger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json +import org.jetbrains.annotations.VisibleForTesting import java.net.URI +import java.net.URLEncoder import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse +import java.nio.charset.StandardCharsets import java.time.Duration import kotlin.collections.filterNot @@ -31,14 +40,115 @@ internal class GitHubClient( coerceInputValues = true } + /** + * Free-text search needs `/search/issues`; every other filter is expressible on the plain + * issue list, which has the far more generous rate limit, so text decides the route. + */ suspend fun fetchIssues( repo: RepoCoordinates, token: String?, - state: String = "open", - perPage: Int = 50, - ): List = + query: IssueQuery, + ): List { + val issues = + if (query.text.isBlank()) { + get(listIssuesUri(repo, query), token) { json.decodeFromString>(it) } + } else { + get(searchIssuesUri(repo, query), token) { json.decodeFromString(it).items } + } + return issues.filterNot { it.isPullRequest } + } + + suspend fun fetchLabels( + repo: RepoCoordinates, + token: String?, + ): List = + get(URI.create("$baseUrl/repos/${repo.owner}/${repo.name}/labels?per_page=$OPTIONS_PER_PAGE"), token) { + json.decodeFromString>(it) + } + + suspend fun fetchMilestones( + repo: RepoCoordinates, + token: String?, + ): List = + get( + URI.create("$baseUrl/repos/${repo.owner}/${repo.name}/milestones?state=all&per_page=$OPTIONS_PER_PAGE"), + token, + ) { json.decodeFromString>(it) } + + /** Users the repo can assign issues to; needs push access, so it 403s for most read-only tokens. */ + suspend fun fetchAssignableUsers( + repo: RepoCoordinates, + token: String?, + ): List = + get(URI.create("$baseUrl/repos/${repo.owner}/${repo.name}/assignees?per_page=$OPTIONS_PER_PAGE"), token) { + json.decodeFromString>(it) + } + + @VisibleForTesting + fun listIssuesUri( + repo: RepoCoordinates, + query: IssueQuery, + ): URI { + val params = + buildList { + add("state" to query.state.listParam()) + add("sort" to query.sortField.apiParam()) + add("direction" to query.sortDirection.apiParam()) + add("per_page" to query.limit.toString()) + if (query.labels.isNotEmpty()) add("labels" to query.labels.joinToString(",")) + query.author?.let { add("creator" to it) } + when (val assignee = query.assignee) { + null -> Unit + AssigneeFilter.Unassigned -> add("assignee" to "none") + is AssigneeFilter.User -> add("assignee" to assignee.login) + } + when (val milestone = query.milestone) { + null -> Unit + MilestoneFilter.None -> add("milestone" to "none") + is MilestoneFilter.Named -> add("milestone" to milestone.milestone.number.toString()) + } + }.joinToString("&") { (name, value) -> "$name=${encode(value)}" } + return URI.create("$baseUrl/repos/${repo.owner}/${repo.name}/issues?$params") + } + + @VisibleForTesting + fun searchIssuesUri( + repo: RepoCoordinates, + query: IssueQuery, + ): URI { + val terms = + buildList { + add("repo:${repo.owner}/${repo.name}") + add("is:issue") + query.state.searchTerm()?.let(::add) + query.labels.forEach { add("label:${quote(it)}") } + query.author?.let { add("author:$it") } + when (val assignee = query.assignee) { + null -> Unit + AssigneeFilter.Unassigned -> add("no:assignee") + is AssigneeFilter.User -> add("assignee:${assignee.login}") + } + when (val milestone = query.milestone) { + null -> Unit + MilestoneFilter.None -> add("no:milestone") + is MilestoneFilter.Named -> add("milestone:${quote(milestone.milestone.title)}") + } + add(query.text.trim()) + } + val params = + "q=${encode(terms.joinToString(" "))}" + + "&sort=${query.sortField.apiParam()}" + + "&order=${query.sortDirection.apiParam()}" + + "&per_page=${query.limit}" + return URI.create("$baseUrl/search/issues?$params") + } + + private suspend fun get( + uri: URI, + token: String?, + decode: (String) -> T, + ): T = withContext(Dispatchers.IO) { - val uri = URI.create("$baseUrl/repos/${repo.owner}/${repo.name}/issues?state=$state&per_page=$perPage") val requestBuilder = HttpRequest .newBuilder(uri) @@ -52,13 +162,11 @@ internal class GitHubClient( val response = http.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()) if (response.statusCode() !in 200..299) { - thisLogger().warn("GitHub API returned ${response.statusCode()} for $repo") + thisLogger().warn("GitHub API returned ${response.statusCode()} for ${uri.path}") throw GitHubApiException(describeError(response.statusCode())) } - json - .decodeFromString>(response.body()) - .filterNot { it.isPullRequest } + decode(response.body()) } private fun describeError(status: Int): String = @@ -66,6 +174,44 @@ internal class GitHubClient( 401 -> "Authentication failed (401). Check your GitHub token." 403 -> "Access forbidden or rate limit exceeded (403)." 404 -> "Repository not found (404). Check the owner/name and token scope." + 422 -> "GitHub rejected the search query (422)." else -> "GitHub API request failed with status $status." } + + private companion object { + /** Filter dropdowns list every value at once; GitHub caps a page at 100. */ + const val OPTIONS_PER_PAGE = 100 + + fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8) + + /** Label names and milestone titles may contain spaces, which would split the search term. */ + fun quote(value: String): String = "\"" + value.replace("\"", "") + "\"" + + fun IssueStateFilter.listParam(): String = + when (this) { + IssueStateFilter.OPEN -> "open" + IssueStateFilter.CLOSED -> "closed" + IssueStateFilter.ALL -> "all" + } + + fun IssueStateFilter.searchTerm(): String? = + when (this) { + IssueStateFilter.OPEN -> "is:open" + IssueStateFilter.CLOSED -> "is:closed" + IssueStateFilter.ALL -> null + } + + fun IssueSortField.apiParam(): String = + when (this) { + IssueSortField.CREATED -> "created" + IssueSortField.UPDATED -> "updated" + IssueSortField.COMMENTS -> "comments" + } + + fun IssueSortDirection.apiParam(): String = + when (this) { + IssueSortDirection.ASC -> "asc" + IssueSortDirection.DESC -> "desc" + } + } } diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubDto.kt b/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubDto.kt index cc71e1b..7b690a6 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubDto.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubDto.kt @@ -15,11 +15,23 @@ internal data class GitHubUserDto( @SerialName("avatar_url") val avatarUrl: String? = null, ) +@Serializable +internal data class GitHubMilestoneDto( + val number: Int, + val title: String, +) + @Serializable internal data class GitHubPullRequestRefDto( val url: String? = null, ) +/** `/search/issues` wraps the same issue objects in a result envelope. */ +@Serializable +internal data class GitHubSearchResultDto( + val items: List = emptyList(), +) + /** Wire model for the GitHub REST API */ @Serializable internal data class GitHubIssueDto( @@ -29,6 +41,7 @@ internal data class GitHubIssueDto( val body: String? = null, val labels: List = emptyList(), val assignee: GitHubUserDto? = null, + val milestone: GitHubMilestoneDto? = null, val user: GitHubUserDto? = null, val comments: Int = 0, @SerialName("html_url") val htmlUrl: String, diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubIssueProvider.kt b/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubIssueProvider.kt index 843633f..ef12e2b 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubIssueProvider.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubIssueProvider.kt @@ -1,7 +1,9 @@ package com.github.vhrabar.issuehub.provider.github import com.github.vhrabar.issuehub.model.Issue +import com.github.vhrabar.issuehub.model.IssueFilterOptions import com.github.vhrabar.issuehub.model.IssueLabel +import com.github.vhrabar.issuehub.model.IssueMilestone import com.github.vhrabar.issuehub.model.IssueQuery import com.github.vhrabar.issuehub.model.IssueState import com.github.vhrabar.issuehub.provider.IssueProvider @@ -9,13 +11,6 @@ import com.github.vhrabar.issuehub.settings.IssueHubSecrets import com.intellij.openapi.project.Project import kotlin.collections.map -private fun IssueState.toApiParam(): String = - when (this) { - IssueState.OPEN -> "open" - IssueState.CLOSED -> "closed" - IssueState.OTHER -> "all" - } - private fun GitHubIssueDto.toIssue(): Issue = Issue( id = number, @@ -31,6 +26,7 @@ private fun GitHubIssueDto.toIssue(): Issue = labels = labels.map { IssueLabel(it.name, it.color) }, assignee = assignee?.login, assigneeAvatarUrl = assignee?.avatarUrl, + milestone = milestone?.let { IssueMilestone(it.number, it.title) }, author = user?.login, authorAvatarUrl = user?.avatarUrl, commentCount = comments, @@ -55,7 +51,28 @@ class GitHubIssueProvider : IssueProvider { ): List { val repo = RepoDetector.detect(project) ?: return emptyList() val token = IssueHubSecrets.getToken(identifier) - return client.fetchIssues(repo, token, query.state.toApiParam(), query.limit).map { it.toIssue() } + return client.fetchIssues(repo, token, query).map { it.toIssue() } + } + + /** + * Each dimension is fetched independently: `/assignees` needs push access and 403s for + * read-only tokens, and losing the assignee dropdown shouldn't cost us labels too. + */ + override suspend fun fetchFilterOptions(project: Project): IssueFilterOptions { + val repo = RepoDetector.detect(project) ?: return IssueFilterOptions() + val token = IssueHubSecrets.getToken(identifier) + val assignees = runCatching { client.fetchAssignableUsers(repo, token).map { it.login } }.getOrDefault(emptyList()) + return IssueFilterOptions( + labels = + runCatching { client.fetchLabels(repo, token).map { IssueLabel(it.name, it.color) } } + .getOrDefault(emptyList()), + assignees = assignees, + milestones = + runCatching { client.fetchMilestones(repo, token).map { IssueMilestone(it.number, it.title) } } + .getOrDefault(emptyList()), + // GitHub has no "issue authors" endpoint; collaborators are the closest cheap stand-in. + authors = assignees, + ) } companion object { From c9b357aed2473b01e39ccbe93e68e242926d23d6 Mon Sep 17 00:00:00 2001 From: Vedran Hrabar Date: Tue, 28 Jul 2026 15:51:45 +0200 Subject: [PATCH 06/20] feat(IssueFilterBar): add filter bar with search and sorting capabilities for issues Signed-off-by: Vedran Hrabar --- .../issuehub/toolWindow/IssueFilterBar.kt | 341 ++++++++++++++++++ .../toolWindow/IssueHubToolWindowFactory.kt | 70 +++- .../messages/IssueHubBundle.properties | 24 ++ 3 files changed, 418 insertions(+), 17 deletions(-) create mode 100644 src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueFilterBar.kt diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueFilterBar.kt b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueFilterBar.kt new file mode 100644 index 0000000..59ebe87 --- /dev/null +++ b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueFilterBar.kt @@ -0,0 +1,341 @@ +package com.github.vhrabar.issuehub.toolWindow + +import com.github.vhrabar.issuehub.IssueHubBundle +import com.github.vhrabar.issuehub.model.AssigneeFilter +import com.github.vhrabar.issuehub.model.IssueFilterOptions +import com.github.vhrabar.issuehub.model.IssueQuery +import com.github.vhrabar.issuehub.model.IssueSortDirection +import com.github.vhrabar.issuehub.model.IssueSortField +import com.github.vhrabar.issuehub.model.IssueStateFilter +import com.github.vhrabar.issuehub.model.MilestoneFilter +import com.intellij.ide.DataManager +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.actionSystem.KeepPopupOnPerform +import com.intellij.openapi.project.DumbAwareToggleAction +import com.intellij.openapi.ui.popup.JBPopup +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.ui.DocumentAdapter +import com.intellij.ui.SearchTextField +import com.intellij.ui.components.ActionLink +import com.intellij.ui.components.DropDownLink +import com.intellij.ui.components.JBPanel +import com.intellij.ui.components.panels.VerticalLayout +import com.intellij.util.Alarm +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.WrapLayout +import java.awt.BorderLayout +import java.awt.FlowLayout +import java.awt.event.KeyAdapter +import java.awt.event.KeyEvent +import javax.swing.JComponent +import javax.swing.event.DocumentEvent + +/** + * Search field plus the filter and sort dropdowns above the issue list. + * + * Owns the current [query] and reports every change through [onQueryChanged]; it never touches + * the list itself. + */ +internal class IssueFilterBar( + parent: Disposable, + trailing: JComponent, + private val onQueryChanged: (IssueQuery) -> Unit, +) : JBPanel(VerticalLayout(JBUI.scale(4))) { + var query: IssueQuery = IssueQuery() + private set + + private var options = IssueFilterOptions() + + /** Re-renders each link's text after the query changes; filled in as the links are built. */ + private val linkUpdaters = mutableListOf<() -> Unit>() + + private val searchField = + SearchTextField(false).apply { + textEditor.emptyText.text = IssueHubBundle["filter.search.placeholder"] + } + + // Every keystroke would otherwise cost a request against the rate-limited search endpoint. + private val searchAlarm = Alarm(Alarm.ThreadToUse.SWING_THREAD, parent) + + private val resetLink = + ActionLink(IssueHubBundle["filter.reset"]) { + searchField.text = "" + updateQuery { IssueQuery(sortField = it.sortField, sortDirection = it.sortDirection, limit = it.limit) } + } + + init { + searchField.addDocumentListener( + object : DocumentAdapter() { + override fun textChanged(e: DocumentEvent) = scheduleSearch() + }, + ) + searchField.addKeyboardListener( + object : KeyAdapter() { + override fun keyPressed(e: KeyEvent) { + if (e.keyCode == KeyEvent.VK_ENTER) { + searchAlarm.cancelAllRequests() + updateQuery { it } + } + } + }, + ) + + add( + JBPanel>(BorderLayout(JBUI.scale(4), 0)).apply { + add(searchField, BorderLayout.CENTER) + add(trailing, BorderLayout.EAST) + }, + ) + add( + // Tool windows are narrow, so the row has to wrap rather than clip. + JBPanel>(WrapLayout(FlowLayout.LEFT, JBUI.scale(8), JBUI.scale(2))).apply { + add(stateLink()) + add(authorLink()) + add(assigneeLink()) + add(labelsLink()) + add(milestoneLink()) + add(sortLink()) + add(resetLink) + }, + ) + border = JBUI.Borders.empty(4) + refreshLinks() + } + + /** Replaces the values offered by the dropdowns; the current selection is left untouched. */ + fun setOptions(options: IssueFilterOptions) { + this.options = options + } + + private fun scheduleSearch() { + searchAlarm.cancelAllRequests() + searchAlarm.addRequest({ updateQuery { it } }, SEARCH_DEBOUNCE_MS) + } + + /** Folds [transform] and the live search text into the query, notifying only on a real change. */ + private fun updateQuery(transform: (IssueQuery) -> IssueQuery) { + val updated = transform(query).copy(text = searchField.text.trim()) + if (updated == query) return + query = updated + refreshLinks() + onQueryChanged(updated) + } + + private fun refreshLinks() { + linkUpdaters.forEach { it() } + resetLink.isVisible = query.isFiltered + } + + private fun stateLink() = + choiceLink( + name = IssueHubBundle["filter.state"], + choices = { IssueStateFilter.entries.map { Choice(stateText(it), it) } }, + // ALL already is the "any" entry, and state is never unset. + includeAny = false, + selected = { query.state }, + display = ::stateText, + onSelect = { picked -> updateQuery { it.copy(state = picked ?: IssueStateFilter.OPEN) } }, + ) + + private fun authorLink() = + choiceLink( + name = IssueHubBundle["filter.author"], + choices = { options.authors.map { Choice(it, it) } }, + selected = { query.author }, + display = { it }, + onSelect = { picked -> updateQuery { it.copy(author = picked) } }, + ) + + private fun assigneeLink() = + choiceLink( + name = IssueHubBundle["filter.assignee"], + choices = { + listOf(Choice(IssueHubBundle["filter.assignee.unassigned"], AssigneeFilter.Unassigned as AssigneeFilter)) + + options.assignees.map { Choice(it, AssigneeFilter.User(it)) } + }, + selected = { query.assignee }, + display = ::assigneeText, + onSelect = { picked -> updateQuery { it.copy(assignee = picked) } }, + ) + + private fun milestoneLink() = + choiceLink( + name = IssueHubBundle["filter.milestone"], + choices = { + listOf(Choice(IssueHubBundle["filter.milestone.none"], MilestoneFilter.None as MilestoneFilter)) + + options.milestones.map { Choice(it.title, MilestoneFilter.Named(it)) } + }, + selected = { query.milestone }, + display = ::milestoneText, + onSelect = { picked -> updateQuery { it.copy(milestone = picked) } }, + ) + + /** Labels are the one dimension GitHub can intersect, so this popup stays open and multi-selects. */ + private fun labelsLink(): DropDownLink = + dropDownLink(IssueHubBundle["filter.label"], { labelsText() }) { host -> + val group = DefaultActionGroup() + group.add( + toggle(IssueHubBundle["filter.any"], { query.labels.isEmpty() }) { + updateQuery { it.copy(labels = emptySet()) } + }, + ) + options.labels.forEach { label -> + group.add( + toggle(label.name, { label.name in query.labels }) { + updateQuery { + val labels = if (label.name in it.labels) it.labels - label.name else it.labels + label.name + it.copy(labels = labels) + } + }, + ) + } + popup(IssueHubBundle["filter.label"], group, host) + } + + private fun sortLink(): DropDownLink = + dropDownLink(IssueHubBundle["filter.sort"], { sortText() }) { host -> + val group = DefaultActionGroup() + IssueSortField.entries.forEach { field -> + group.add( + toggle(sortFieldText(field), { query.sortField == field }, keepOpen = false) { + updateQuery { it.copy(sortField = field) } + }, + ) + } + group.addSeparator() + IssueSortDirection.entries.forEach { direction -> + group.add( + toggle(sortDirectionText(direction), { query.sortDirection == direction }, keepOpen = false) { + updateQuery { it.copy(sortDirection = direction) } + }, + ) + } + popup(IssueHubBundle["filter.sort"], group, host) + } + + /** A single-select dropdown over [choices], with an "Any" entry that clears the dimension. */ + private fun choiceLink( + name: String, + choices: () -> List>, + selected: () -> T?, + display: (T) -> String, + onSelect: (T?) -> Unit, + includeAny: Boolean = true, + ): DropDownLink = + dropDownLink(name, { selected()?.let(display) ?: IssueHubBundle["filter.any"] }) { host -> + val group = DefaultActionGroup() + if (includeAny) { + group.add(toggle(IssueHubBundle["filter.any"], { selected() == null }, keepOpen = false) { onSelect(null) }) + } + choices().forEach { choice -> + group.add( + toggle(choice.text, { selected() == choice.value }, keepOpen = false) { onSelect(choice.value) }, + ) + } + popup(name, group, host) + } + + /** Wires a link so its text tracks [value] whenever the query changes. */ + private fun dropDownLink( + name: String, + value: () -> String, + popupBuilder: (DropDownLink) -> JBPopup, + ): DropDownLink { + val link = DropDownLink(name, popupBuilder) + linkUpdaters += { link.text = IssueHubBundle["filter.chip", name, value()] } + return link + } + + private fun popup( + title: String, + group: ActionGroup, + host: JComponent, + ): JBPopup = + JBPopupFactory.getInstance().createActionGroupPopup( + title, + group, + DataManager.getInstance().getDataContext(host), + JBPopupFactory.ActionSelectionAid.SPEEDSEARCH, + true, + ) + + private fun toggle( + text: String, + selected: () -> Boolean, + keepOpen: Boolean = true, + onToggle: () -> Unit, + ) = object : DumbAwareToggleAction(text) { + init { + templatePresentation.keepPopupOnPerform = + if (keepOpen) KeepPopupOnPerform.Always else KeepPopupOnPerform.Never + } + + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun isSelected(e: AnActionEvent) = selected() + + override fun setSelected( + e: AnActionEvent, + state: Boolean, + ) = onToggle() + } + + private fun labelsText(): String = + when (query.labels.size) { + 0 -> IssueHubBundle["filter.any"] + 1 -> query.labels.first() + else -> IssueHubBundle["filter.label.count", query.labels.size] + } + + private fun sortText(): String = + IssueHubBundle[ + "filter.sort.value", + sortFieldText(query.sortField), + sortDirectionText(query.sortDirection), + ] + + private fun stateText(state: IssueStateFilter): String = + when (state) { + IssueStateFilter.OPEN -> IssueHubBundle["filter.state.open"] + IssueStateFilter.CLOSED -> IssueHubBundle["filter.state.closed"] + IssueStateFilter.ALL -> IssueHubBundle["filter.state.all"] + } + + private fun assigneeText(filter: AssigneeFilter): String = + when (filter) { + AssigneeFilter.Unassigned -> IssueHubBundle["filter.assignee.unassigned"] + is AssigneeFilter.User -> filter.login + } + + private fun milestoneText(filter: MilestoneFilter): String = + when (filter) { + MilestoneFilter.None -> IssueHubBundle["filter.milestone.none"] + is MilestoneFilter.Named -> filter.milestone.title + } + + private fun sortFieldText(field: IssueSortField): String = + when (field) { + IssueSortField.CREATED -> IssueHubBundle["filter.sort.created"] + IssueSortField.UPDATED -> IssueHubBundle["filter.sort.updated"] + IssueSortField.COMMENTS -> IssueHubBundle["filter.sort.comments"] + } + + private fun sortDirectionText(direction: IssueSortDirection): String = + when (direction) { + IssueSortDirection.ASC -> IssueHubBundle["filter.sort.asc"] + IssueSortDirection.DESC -> IssueHubBundle["filter.sort.desc"] + } + + private data class Choice( + val text: String, + val value: T, + ) + + private companion object { + const val SEARCH_DEBOUNCE_MS = 400 + } +} diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueHubToolWindowFactory.kt b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueHubToolWindowFactory.kt index 73678dc..dc4bd8b 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueHubToolWindowFactory.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueHubToolWindowFactory.kt @@ -2,11 +2,14 @@ package com.github.vhrabar.issuehub.toolWindow import com.github.vhrabar.issuehub.IssueHubBundle import com.github.vhrabar.issuehub.model.Issue +import com.github.vhrabar.issuehub.model.IssueFilterOptions import com.github.vhrabar.issuehub.model.IssueQuery +import com.github.vhrabar.issuehub.model.optionsFrom import com.github.vhrabar.issuehub.provider.IssueProvider import com.github.vhrabar.issuehub.provider.github.GitHubIssueProvider import com.github.vhrabar.issuehub.settings.IssueHubSecrets import com.intellij.ide.BrowserUtil +import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.project.Project import com.intellij.openapi.ui.Messages @@ -51,6 +54,7 @@ class IssueHubToolWindowFactory : ToolWindowFactory { false, ) content.description = source + content.setDisposer(panel) toolWindow.contentManager.addContent(content) } @@ -58,7 +62,8 @@ class IssueHubToolWindowFactory : ToolWindowFactory { private class IssueHubToolWindowPanel( private val project: Project, - ) : JBPanel(BorderLayout()) { + ) : JBPanel(BorderLayout()), + Disposable { private val listModel = DefaultListModel() private val issueList = object : JBList(listModel) { @@ -109,9 +114,19 @@ class IssueHubToolWindowFactory : ToolWindowFactory { ) } + /** Values the provider enumerated, and values merely seen on issues we've already loaded. */ + private var providerOptions = IssueFilterOptions() + private var discoveredOptions = IssueFilterOptions() + + /** Guards against a slow response for an abandoned query overwriting a newer one. */ + private var requestId = 0 + + private val filterBar = + IssueFilterBar(this, buildActions()) { refresh(reloadOptions = false) } + init { issueList.cellRenderer = IssueCellRenderer(avatarLoader) - add(buildToolbar(), BorderLayout.NORTH) + add(filterBar, BorderLayout.NORTH) add(center, BorderLayout.CENTER) issueList.addMouseListener( @@ -124,24 +139,25 @@ class IssueHubToolWindowFactory : ToolWindowFactory { }, ) - refresh() + refresh(reloadOptions = true) } - private fun buildToolbar(): JBPanel<*> { - val toolbar = JBPanel>(FlowLayout(FlowLayout.LEFT, JBUI.scale(4), JBUI.scale(4))) - toolbar.border = JBUI.Borders.empty(4) - toolbar.add( + override fun dispose() = Unit + + private fun buildActions(): JBPanel<*> { + val actions = JBPanel>(FlowLayout(FlowLayout.RIGHT, JBUI.scale(4), 0)) + actions.add( JButton(IssueHubBundle["toolWindow.refresh"]).apply { - addActionListener { refresh() } + addActionListener { refresh(reloadOptions = true) } }, ) // TODO: temporary placeholder until a proper settings UI exists. - toolbar.add( + actions.add( JButton(IssueHubBundle["toolWindow.addToken"]).apply { addActionListener { promptForToken() } }, ) - return toolbar + return actions } private fun showStatus(text: String) { @@ -149,30 +165,50 @@ class IssueHubToolWindowFactory : ToolWindowFactory { cardLayout.show(center, STATUS_CARD) } - private fun showIssues(issues: List) { + private fun showIssues( + issues: List, + query: IssueQuery, + ) { listModel.clear() if (issues.isEmpty()) { - showStatus(IssueHubBundle["toolWindow.empty"]) + showStatus(IssueHubBundle[if (query.isFiltered) "toolWindow.emptyFiltered" else "toolWindow.empty"]) return } issues.forEach(listModel::addElement) cardLayout.show(center, LIST_CARD) } - private fun refresh() { + /** + * [reloadOptions] re-reads the label/assignee/milestone lists too; they barely ever change, + * so filter and search changes skip that round trip and only re-run the query. + */ + private fun refresh(reloadOptions: Boolean) { val provider = IssueProvider.firstApplicable(project) if (provider == null) { showStatus(IssueHubBundle["toolWindow.noProvider"]) return } + val query = filterBar.query + val id = ++requestId showStatus(IssueHubBundle["toolWindow.loading"]) // fetchIssues is a suspend fn doing network IO ApplicationManager.getApplication().executeOnPooledThread { - val result = runCatching { runBlocking { provider.fetchIssues(project, IssueQuery()) } } + val result = runCatching { runBlocking { provider.fetchIssues(project, query) } } + val options = + if (reloadOptions) { + runCatching { runBlocking { provider.fetchFilterOptions(project) } }.getOrNull() + } else { + null + } ApplicationManager.getApplication().invokeLater { + if (id != requestId) return@invokeLater + options?.let { providerOptions = it } result - .onSuccess { showIssues(it) } - .onFailure { showStatus(IssueHubBundle["toolWindow.error", it.message ?: it.toString()]) } + .onSuccess { issues -> + discoveredOptions = discoveredOptions.mergedWith(optionsFrom(issues)) + filterBar.setOptions(providerOptions.mergedWith(discoveredOptions)) + showIssues(issues, query) + }.onFailure { showStatus(IssueHubBundle["toolWindow.error", it.message ?: it.toString()]) } } } } @@ -192,7 +228,7 @@ class IssueHubToolWindowFactory : ToolWindowFactory { IssueHubBundle["toolWindow.addToken.saved"], IssueHubBundle["toolWindow.addToken.title"], ) - refresh() + refresh(reloadOptions = true) } } } diff --git a/src/main/resources/messages/IssueHubBundle.properties b/src/main/resources/messages/IssueHubBundle.properties index 5d7c973..36fa2c4 100644 --- a/src/main/resources/messages/IssueHubBundle.properties +++ b/src/main/resources/messages/IssueHubBundle.properties @@ -8,9 +8,33 @@ toolWindow.addToken.saved=Token saved to secure storage. toolWindow.refresh=Refresh toolWindow.loading=Loading issues… toolWindow.empty=No issues found. +toolWindow.emptyFiltered=No issues match the current search and filters. toolWindow.noProvider=No issue provider is configured for this project. toolWindow.error=Failed to load issues: {0} +filter.search.placeholder=Search issues +filter.chip={0}: {1} +filter.any=Any +filter.reset=Reset +filter.state=State +filter.state.open=Open +filter.state.closed=Closed +filter.state.all=All +filter.author=Author +filter.assignee=Assignee +filter.assignee.unassigned=Unassigned +filter.label=Label +filter.label.count={0} labels +filter.milestone=Milestone +filter.milestone.none=No milestone +filter.sort=Sort +filter.sort.value={0}, {1} +filter.sort.created=Created +filter.sort.updated=Updated +filter.sort.comments=Comments +filter.sort.asc=ascending +filter.sort.desc=descending + issue.state.open=OPEN issue.state.closed=CLOSED issue.state.other= From 1503892205de7c56bd6afcd5a25e56efed2b8028 Mon Sep 17 00:00:00 2001 From: Vedran Hrabar Date: Wed, 29 Jul 2026 13:24:08 +0200 Subject: [PATCH 07/20] feat(IssueActor): introduce IssueActor model for author and assignee representation #18 Signed-off-by: Vedran Hrabar --- .../github/vhrabar/issuehub/model/Issue.kt | 16 ++++++++++---- .../vhrabar/issuehub/model/IssueQuery.kt | 4 ++-- .../provider/github/GitHubIssueProvider.kt | 9 ++++---- .../issuehub/toolWindow/IssueCellRenderer.kt | 22 ++++++++----------- 4 files changed, 28 insertions(+), 23 deletions(-) diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/model/Issue.kt b/src/main/kotlin/com/github/vhrabar/issuehub/model/Issue.kt index b679a53..2afd95a 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/model/Issue.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/model/Issue.kt @@ -18,6 +18,16 @@ data class IssueMilestone( val title: String, ) +/** + * Generalized issue actor, author, an assignee, or the actor behind a + * timeline event. [login] identifies the account and is what filters match on; + * [avatarUrl] is null when the provider doesn't publish a picture. + */ +data class IssueActor( + val login: String, + val avatarUrl: String? = null, +) + /** Provider-neutral repr. of tracked issues */ data class Issue( val id: Int, @@ -26,11 +36,9 @@ data class Issue( val state: IssueState, val body: String? = null, val labels: List = emptyList(), - val assignee: String? = null, - val assigneeAvatarUrl: String? = null, + val assignee: IssueActor? = null, val milestone: IssueMilestone? = null, - val author: String? = null, - val authorAvatarUrl: String? = null, + val author: IssueActor? = null, val commentCount: Int = 0, val url: String, val createdAt: String, diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/model/IssueQuery.kt b/src/main/kotlin/com/github/vhrabar/issuehub/model/IssueQuery.kt index 2cdd55b..291d0aa 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/model/IssueQuery.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/model/IssueQuery.kt @@ -83,7 +83,7 @@ data class IssueFilterOptions( fun optionsFrom(issues: List): IssueFilterOptions = IssueFilterOptions( labels = issues.flatMap { it.labels }, - assignees = issues.mapNotNull { it.assignee }, + assignees = issues.mapNotNull { it.assignee?.login }, milestones = issues.mapNotNull { it.milestone }, - authors = issues.mapNotNull { it.author }, + authors = issues.mapNotNull { it.author?.login }, ).mergedWith(IssueFilterOptions()) diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubIssueProvider.kt b/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubIssueProvider.kt index ef12e2b..2cddab1 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubIssueProvider.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubIssueProvider.kt @@ -1,6 +1,7 @@ package com.github.vhrabar.issuehub.provider.github import com.github.vhrabar.issuehub.model.Issue +import com.github.vhrabar.issuehub.model.IssueActor import com.github.vhrabar.issuehub.model.IssueFilterOptions import com.github.vhrabar.issuehub.model.IssueLabel import com.github.vhrabar.issuehub.model.IssueMilestone @@ -11,6 +12,8 @@ import com.github.vhrabar.issuehub.settings.IssueHubSecrets import com.intellij.openapi.project.Project import kotlin.collections.map +private fun GitHubUserDto.toActor(): IssueActor = IssueActor(login, avatarUrl) + private fun GitHubIssueDto.toIssue(): Issue = Issue( id = number, @@ -24,11 +27,9 @@ private fun GitHubIssueDto.toIssue(): Issue = }, body = body, labels = labels.map { IssueLabel(it.name, it.color) }, - assignee = assignee?.login, - assigneeAvatarUrl = assignee?.avatarUrl, + assignee = assignee?.toActor(), milestone = milestone?.let { IssueMilestone(it.number, it.title) }, - author = user?.login, - authorAvatarUrl = user?.avatarUrl, + author = user?.toActor(), commentCount = comments, url = htmlUrl, createdAt = createdAt, diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueCellRenderer.kt b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueCellRenderer.kt index e8b99b3..fdd8144 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueCellRenderer.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueCellRenderer.kt @@ -170,17 +170,12 @@ internal class IssueCellRenderer( stateText.clear() stateText.append(stateLabel(value.state), grayed) - // Prefer the assignee; fall back to the author, keeping login and avatar url in step. - val (account, avatarUrl) = - if (value.assignee != null) { - value.assignee to value.assigneeAvatarUrl - } else { - value.author to value.authorAvatarUrl - } - avatar.icon = account?.let { avatarLoader.avatar(avatarUrl, IssueAvatarIcon(it)) } + // Prefer the assignee; fall back to the author. + val account = value.assignee ?: value.author + avatar.icon = account?.let { avatarLoader.avatar(it.avatarUrl, IssueAvatarIcon(it.login)) } avatar.toolTipText = value.assignee - ?.let { IssueHubBundle["issue.assignedTo", it] } - ?: value.author?.let { IssueHubBundle["issue.openedBy", it] } + ?.let { IssueHubBundle["issue.assignedTo", it.login] } + ?: value.author?.let { IssueHubBundle["issue.openedBy", it.login] } comments.clear() if (value.commentCount > 0) { @@ -194,11 +189,12 @@ internal class IssueCellRenderer( private fun metaText(value: Issue): String { val created = formatDate(value.createdAt) + val author = value.author?.login return when { - value.author != null && created != null -> - IssueHubBundle["issue.meta.createdBy", value.displayNumber, created, value.author] + author != null && created != null -> + IssueHubBundle["issue.meta.createdBy", value.displayNumber, created, author] created != null -> IssueHubBundle["issue.meta.created", value.displayNumber, created] - value.author != null -> IssueHubBundle["issue.meta.by", value.displayNumber, value.author] + author != null -> IssueHubBundle["issue.meta.by", value.displayNumber, author] else -> value.displayNumber } } From a5217b8679e1547fb776496c15d2b951b9508dcf Mon Sep 17 00:00:00 2001 From: Vedran Hrabar Date: Wed, 29 Jul 2026 13:43:24 +0200 Subject: [PATCH 08/20] feat(issue detail): add model and inf. required to handle Issue details #18 Signed-off-by: Vedran Hrabar --- .../github/vhrabar/issuehub/model/Issue.kt | 11 ++++++++ .../issuehub/provider/IssueProvider.kt | 12 +++++++++ .../issuehub/provider/github/GitHubClient.kt | 25 ++++++++++++++++++- .../issuehub/provider/github/GitHubDto.kt | 1 + .../provider/github/GitHubIssueProvider.kt | 14 +++++++++++ 5 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/model/Issue.kt b/src/main/kotlin/com/github/vhrabar/issuehub/model/Issue.kt index 2afd95a..4e35b9f 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/model/Issue.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/model/Issue.kt @@ -44,3 +44,14 @@ data class Issue( val createdAt: String, val updatedAt: String, ) + +/** + * All Issue comps + * + * [bodyHtml] is the description already rendered to HTML by the provider + * It is null when the provider only hands back source text + */ +data class IssueDetail( + val issue: Issue, + val bodyHtml: String? = null, +) diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/provider/IssueProvider.kt b/src/main/kotlin/com/github/vhrabar/issuehub/provider/IssueProvider.kt index 62d3b5a..3e82387 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/provider/IssueProvider.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/provider/IssueProvider.kt @@ -1,6 +1,7 @@ package com.github.vhrabar.issuehub.provider import com.github.vhrabar.issuehub.model.Issue +import com.github.vhrabar.issuehub.model.IssueDetail import com.github.vhrabar.issuehub.model.IssueFilterOptions import com.github.vhrabar.issuehub.model.IssueQuery import com.intellij.openapi.extensions.ExtensionPointName @@ -25,6 +26,17 @@ interface IssueProvider { /** values the filter UI can offer; empty when the provider can't enumerate them */ suspend fun fetchFilterOptions(project: Project): IssueFilterOptions = IssueFilterOptions() + /** + * The full issue behind a list row, for the detail view. + * + * Null when the provider can't serve one, so the caller falls back to the row it already + * has instead of failing. + */ + suspend fun fetchIssueDetail( + project: Project, + issue: Issue, + ): IssueDetail? = null + companion object { val EP_NAME: ExtensionPointName = ExtensionPointName.create("com.github.vhrabar.issuehub.issueProvider") diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubClient.kt b/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubClient.kt index ff8d392..eb0335b 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubClient.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubClient.kt @@ -58,6 +58,23 @@ internal class GitHubClient( return issues.filterNot { it.isPullRequest } } + /** + * A single issue, asked for with the `full` media type so the response carries `body_html` + * next to the Markdown source. The list endpoints deliberately stay on the default type: + * rows only ever show the title, and rendered bodies would bloat every page. + */ + suspend fun fetchIssue( + repo: RepoCoordinates, + token: String?, + number: Int, + ): GitHubIssueDto = get(issueUri(repo, number), token, ACCEPT_FULL) { json.decodeFromString(it) } + + @VisibleForTesting + fun issueUri( + repo: RepoCoordinates, + number: Int, + ): URI = URI.create("$baseUrl/repos/${repo.owner}/${repo.name}/issues/$number") + suspend fun fetchLabels( repo: RepoCoordinates, token: String?, @@ -146,6 +163,7 @@ internal class GitHubClient( private suspend fun get( uri: URI, token: String?, + accept: String = ACCEPT_JSON, decode: (String) -> T, ): T = withContext(Dispatchers.IO) { @@ -153,7 +171,7 @@ internal class GitHubClient( HttpRequest .newBuilder(uri) .timeout(Duration.ofSeconds(30)) - .header("Accept", "application/vnd.github+json") + .header("Accept", accept) .header("X-GitHub-Api-Version", "2026-03-10") .GET() if (!token.isNullOrBlank()) { @@ -179,6 +197,11 @@ internal class GitHubClient( } private companion object { + const val ACCEPT_JSON = "application/vnd.github+json" + + /** Adds `body_html` (and `body_text`) alongside the Markdown source. */ + const val ACCEPT_FULL = "application/vnd.github.full+json" + /** Filter dropdowns list every value at once; GitHub caps a page at 100. */ const val OPTIONS_PER_PAGE = 100 diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubDto.kt b/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubDto.kt index 7b690a6..ffa508a 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubDto.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubDto.kt @@ -39,6 +39,7 @@ internal data class GitHubIssueDto( val title: String, val state: String, val body: String? = null, + @SerialName("body_html") val bodyHtml: String? = null, val labels: List = emptyList(), val assignee: GitHubUserDto? = null, val milestone: GitHubMilestoneDto? = null, diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubIssueProvider.kt b/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubIssueProvider.kt index 2cddab1..cacea9f 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubIssueProvider.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubIssueProvider.kt @@ -2,6 +2,7 @@ package com.github.vhrabar.issuehub.provider.github import com.github.vhrabar.issuehub.model.Issue import com.github.vhrabar.issuehub.model.IssueActor +import com.github.vhrabar.issuehub.model.IssueDetail import com.github.vhrabar.issuehub.model.IssueFilterOptions import com.github.vhrabar.issuehub.model.IssueLabel import com.github.vhrabar.issuehub.model.IssueMilestone @@ -55,6 +56,19 @@ class GitHubIssueProvider : IssueProvider { return client.fetchIssues(repo, token, query).map { it.toIssue() } } + /** + * Re-reads the issue rather than trusting the list row + */ + override suspend fun fetchIssueDetail( + project: Project, + issue: Issue, + ): IssueDetail? { + val repo = RepoDetector.detect(project) ?: return null + val token = IssueHubSecrets.getToken(identifier) + val dto = client.fetchIssue(repo, token, issue.id) + return IssueDetail(issue = dto.toIssue(), bodyHtml = dto.bodyHtml) + } + /** * Each dimension is fetched independently: `/assignees` needs push access and 403s for * read-only tokens, and losing the assignee dropdown shouldn't cost us labels too. From 72298fb0a6f108c3bbf819151735512e6b143697 Mon Sep 17 00:00:00 2001 From: Vedran Hrabar Date: Wed, 29 Jul 2026 15:31:26 +0200 Subject: [PATCH 09/20] feat(issue timeline): add sealed interface for issue timeline items with various change types #18 Signed-off-by: Vedran Hrabar --- .../github/vhrabar/issuehub/model/Issue.kt | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/model/Issue.kt b/src/main/kotlin/com/github/vhrabar/issuehub/model/Issue.kt index 4e35b9f..940d7b4 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/model/Issue.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/model/Issue.kt @@ -55,3 +55,84 @@ data class IssueDetail( val issue: Issue, val bodyHtml: String? = null, ) + +/** + * One entry in an issue's history + */ +sealed interface IssueTimelineItem { + val actor: IssueActor? + val at: String + + /** Someone wrote a comment. [bodyHtml] follows the same rules as [IssueDetail.bodyHtml]. */ + data class Comment( + override val actor: IssueActor?, + override val at: String, + val body: String? = null, + val bodyHtml: String? = null, + val url: String? = null, + val edited: Boolean = false, + ) : IssueTimelineItem + + /** The issue was closed or reopened; [reason] is the provider's wording, when it gives one. */ + data class StateChange( + override val actor: IssueActor?, + override val at: String, + val state: IssueState, + val reason: String? = null, + ) : IssueTimelineItem + + data class LabelChange( + override val actor: IssueActor?, + override val at: String, + val label: IssueLabel, + val added: Boolean, + ) : IssueTimelineItem + + data class AssigneeChange( + override val actor: IssueActor?, + override val at: String, + val assignee: IssueActor, + val added: Boolean, + ) : IssueTimelineItem + + data class MilestoneChange( + override val actor: IssueActor?, + override val at: String, + val milestone: IssueMilestone, + val added: Boolean, + ) : IssueTimelineItem + + data class Renamed( + override val actor: IssueActor?, + override val at: String, + val from: String, + val to: String, + ) : IssueTimelineItem + + /** Another issue or pull request linked to this one. */ + data class CrossReferenced( + override val actor: IssueActor?, + override val at: String, + val displayNumber: String, + val title: String, + val url: String, + val isPullRequest: Boolean, + ) : IssueTimelineItem + + /** A commit referenced the issue. */ + data class Referenced( + override val actor: IssueActor?, + override val at: String, + val commitSha: String, + val commitUrl: String? = null, + ) : IssueTimelineItem + + /** + * An entry we can't be modeled. [kind] keeps the provider's own name for it + */ + data class Unknown( + override val actor: IssueActor?, + override val at: String, + val kind: String, + ) : IssueTimelineItem +} From 2e6a08c8384adda307eafaa8c88558b0836217a6 Mon Sep 17 00:00:00 2001 From: Vedran Hrabar Date: Thu, 30 Jul 2026 14:41:09 +0200 Subject: [PATCH 10/20] feat(issue editor): initial issue editor draft #18 Signed-off-by: Vedran Hrabar --- README.md | 16 ++++++++-- .../issuehub/toolWindow/IssueCellRenderer.kt | 13 -------- .../toolWindow/IssueHubToolWindowFactory.kt | 10 +++++-- .../vhrabar/issuehub/toolWindow/IssueIcons.kt | 30 +++++++++++++++---- src/main/resources/META-INF/plugin.xml | 4 +++ .../messages/IssueHubBundle.properties | 12 ++++++++ 6 files changed, 62 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index f297b61..070f650 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,11 @@ repository from a dedicated tool window, without leaving your editor. - Lists issues for the GitHub repository detected from your project's Git remote - Shows issue number, title, labels, and assignee -- Opens any issue in the browser with a double-click +- Search issue titles and bodies, and filter by state, author, assignee, labels or milestone +- Sort by creation date, last update or comment count +- Opens an issue as an editor tab, full width and splittable next to your code, with the description + rendered in the IDE's own styling +- Jumps to the issue on GitHub whenever you need the browser - Stores your GitHub token in the IDE's secure credential store @@ -32,7 +36,15 @@ repository from a dedicated tool window, without leaving your editor. 2. Open the **IssueHub** tool window. 3. Click **Add Token…** and paste a GitHub personal access token (stored in the IDE's secure credential store, never in plain text). -4. Click **Refresh** to load issues. Double-click an issue to open it in your browser. +4. Click **Refresh** to load issues. Double-click an issue to open it as an editor tab, titled with + the issue number; **Open on GitHub** there opens the same issue in your browser. +5. Use the search field and the **State / Author / Assignee / Label / Milestone / Sort** dropdowns to + narrow the list; **Reset** clears everything back to open issues, newest first. + +Filtering and sorting run on GitHub's side, so the results are the whole repository's issues, not +just the ones already on screen. Typing in the search field uses GitHub's search endpoint, which is +rate limited more tightly than the plain issue list, an authenticated token raises that limit +considerably. ### Required token scope diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueCellRenderer.kt b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueCellRenderer.kt index fdd8144..565d9bc 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueCellRenderer.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueCellRenderer.kt @@ -6,7 +6,6 @@ import com.github.vhrabar.issuehub.model.IssueLabel import com.github.vhrabar.issuehub.model.IssueState import com.intellij.icons.AllIcons import com.intellij.ui.ColorUtil -import com.intellij.ui.JBColor import com.intellij.ui.SimpleColoredComponent import com.intellij.ui.SimpleTextAttributes import com.intellij.ui.components.JBLabel @@ -154,15 +153,6 @@ internal class IssueCellRenderer( swatchImageUrl(color)?.let { """""" } ?: """""" - /** GitHub picks label colors against a white page, so lift them when the surface is dark. */ - private fun labelTint( - label: IssueLabel, - background: Color, - ): Color { - val base = label.color?.let { ColorUtil.fromHex(it, null) } ?: NEUTRAL_LABEL - return if (ColorUtil.isDark(background)) ColorUtil.brighter(base, 1) else base - } - private fun renderTrailing( value: Issue, grayed: SimpleTextAttributes, @@ -300,9 +290,6 @@ internal class IssueCellRenderer( } private companion object { - /** Fallback for labels the API returned without a color. */ - val NEUTRAL_LABEL = JBColor(Color(0x9AA7B0), Color(0x6C707E)) - fun borderless() = SimpleColoredComponent().apply { isOpaque = false diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueHubToolWindowFactory.kt b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueHubToolWindowFactory.kt index dc4bd8b..afac0d5 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueHubToolWindowFactory.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueHubToolWindowFactory.kt @@ -1,6 +1,7 @@ package com.github.vhrabar.issuehub.toolWindow import com.github.vhrabar.issuehub.IssueHubBundle +import com.github.vhrabar.issuehub.editor.IssueVirtualFile import com.github.vhrabar.issuehub.model.Issue import com.github.vhrabar.issuehub.model.IssueFilterOptions import com.github.vhrabar.issuehub.model.IssueQuery @@ -8,7 +9,6 @@ import com.github.vhrabar.issuehub.model.optionsFrom import com.github.vhrabar.issuehub.provider.IssueProvider import com.github.vhrabar.issuehub.provider.github.GitHubIssueProvider import com.github.vhrabar.issuehub.settings.IssueHubSecrets -import com.intellij.ide.BrowserUtil import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.project.Project @@ -133,7 +133,7 @@ class IssueHubToolWindowFactory : ToolWindowFactory { object : MouseAdapter() { override fun mouseClicked(e: MouseEvent) { if (e.clickCount == 2) { - issueList.selectedValue?.url?.let { BrowserUtil.browse(it) } + issueList.selectedValue?.let(::openDetail) } } }, @@ -144,6 +144,12 @@ class IssueHubToolWindowFactory : ToolWindowFactory { override fun dispose() = Unit + /** + * Opens [issue] in the editor area rather than inside this tool window, so the description gets + * the full window width and can be split alongside code. + */ + private fun openDetail(issue: Issue) = IssueVirtualFile.open(project, issue) + private fun buildActions(): JBPanel<*> { val actions = JBPanel>(FlowLayout(FlowLayout.RIGHT, JBUI.scale(4), 0)) actions.add( diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueIcons.kt b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueIcons.kt index 627e7a0..14405ba 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueIcons.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueIcons.kt @@ -1,5 +1,6 @@ package com.github.vhrabar.issuehub.toolWindow +import com.github.vhrabar.issuehub.model.IssueLabel import com.github.vhrabar.issuehub.model.IssueState import com.intellij.ui.ColorUtil import com.intellij.ui.JBColor @@ -28,13 +29,30 @@ internal val IssueState.dotColor: Color IssueState.OTHER -> OTHER_COLOR } -/** A filled circle marking issue state, sized to sit on the title baseline. */ +/** Fallback for labels the API returned without a color. */ +private val NEUTRAL_LABEL = JBColor(Color(0x9AA7B0), Color(0x6C707E)) + +/** GitHub picks label colors against a white page, so lift them when the surface is dark. */ +internal fun labelTint( + label: IssueLabel, + background: Color, +): Color { + val base = label.color?.let { ColorUtil.fromHex(it, null) } ?: NEUTRAL_LABEL + return if (ColorUtil.isDark(background)) ColorUtil.brighter(base, 1) else base +} + +/** + * A filled circle marking issue state, sized to sit on the title baseline. + * + * [size] is the box the dot is centred in: the default suits label text, 16 matches an editor tab. + */ internal class IssueStateIcon( private val state: IssueState, + private val size: Int = SIZE, ) : Icon { - override fun getIconWidth(): Int = JBUI.scale(SIZE) + override fun getIconWidth(): Int = JBUI.scale(size) - override fun getIconHeight(): Int = JBUI.scale(SIZE) + override fun getIconHeight(): Int = JBUI.scale(size) override fun paintIcon( c: Component?, @@ -45,8 +63,8 @@ internal class IssueStateIcon( val g2 = g.create() as Graphics2D try { g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) - val d = JBUI.scale(DOT).toDouble() - val offset = (JBUI.scale(SIZE) - d) / 2 + val d = JBUI.scale(size) * DOT_RATIO + val offset = (JBUI.scale(size) - d) / 2 g2.color = state.dotColor g2.fill(Ellipse2D.Double(x + offset, y + offset, d, d)) } finally { @@ -56,7 +74,7 @@ internal class IssueStateIcon( private companion object { const val SIZE = 12 - const val DOT = 8 + const val DOT_RATIO = 2.0 / 3.0 } } diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 2655757..60e01df 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -22,6 +22,10 @@ anchor="left" icon="/icons/issueHub.svg" factoryClass="com.github.vhrabar.issuehub.toolWindow.IssueHubToolWindowFactory"/> + + + + diff --git a/src/main/resources/messages/IssueHubBundle.properties b/src/main/resources/messages/IssueHubBundle.properties index 36fa2c4..6dcb567 100644 --- a/src/main/resources/messages/IssueHubBundle.properties +++ b/src/main/resources/messages/IssueHubBundle.properties @@ -35,6 +35,18 @@ filter.sort.comments=Comments filter.sort.asc=ascending filter.sort.desc=descending +editor.fileType.description=IssueHub issue + +detail.loading=Loading issue… +detail.error=Failed to load issue: {0} +detail.noDescription=No description provided. +detail.openInBrowser=Open on GitHub +detail.refresh=Refresh +detail.byline={0} · opened by {1} on {2} · {3} comments +detail.unknownAuthor=someone +detail.unassigned=Unassigned +detail.noMilestone=No milestone + issue.state.open=OPEN issue.state.closed=CLOSED issue.state.other= From db17e8d1f4178a0b77ae71a2fbc4e7f5494316f0 Mon Sep 17 00:00:00 2001 From: Vedran Hrabar Date: Sat, 1 Aug 2026 14:54:39 +0200 Subject: [PATCH 11/20] feat(issue timeline): implement timeline fetching and model for issue events #18 Signed-off-by: Vedran Hrabar --- .../issuehub/editor/IssueFileEditor.kt | 42 +++++++++ .../editor/IssueFileEditorProvider.kt | 34 +++++++ .../vhrabar/issuehub/editor/IssueFileType.kt | 25 +++++ .../issuehub/editor/IssueVirtualFile.kt | 54 +++++++++++ .../github/vhrabar/issuehub/model/Issue.kt | 14 ++- .../issuehub/provider/github/GitHubClient.kt | 40 ++++++++ .../issuehub/provider/github/GitHubDto.kt | 39 ++++++++ .../provider/github/GitHubIssueProvider.kt | 91 ++++++++++++++++++- 8 files changed, 335 insertions(+), 4 deletions(-) create mode 100644 src/main/kotlin/com/github/vhrabar/issuehub/editor/IssueFileEditor.kt create mode 100644 src/main/kotlin/com/github/vhrabar/issuehub/editor/IssueFileEditorProvider.kt create mode 100644 src/main/kotlin/com/github/vhrabar/issuehub/editor/IssueFileType.kt create mode 100644 src/main/kotlin/com/github/vhrabar/issuehub/editor/IssueVirtualFile.kt diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/editor/IssueFileEditor.kt b/src/main/kotlin/com/github/vhrabar/issuehub/editor/IssueFileEditor.kt new file mode 100644 index 0000000..7e4710c --- /dev/null +++ b/src/main/kotlin/com/github/vhrabar/issuehub/editor/IssueFileEditor.kt @@ -0,0 +1,42 @@ +package com.github.vhrabar.issuehub.editor + +import com.github.vhrabar.issuehub.toolWindow.IssueDetailPanel +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.fileEditor.FileEditorState +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.UserDataHolderBase +import com.intellij.openapi.vfs.VirtualFile +import java.beans.PropertyChangeListener +import java.beans.PropertyChangeSupport +import javax.swing.JComponent + +/** Hosts [IssueDetailPanel] as an editor, which is all the platform needs to give it a tab. */ +internal class IssueFileEditor( + project: Project, + private val file: IssueVirtualFile, +) : UserDataHolderBase(), + FileEditor { + private val panel = IssueDetailPanel(project, file.issue) + + private val listeners = PropertyChangeSupport(this) + + override fun getComponent(): JComponent = panel + + override fun getPreferredFocusedComponent(): JComponent = panel.preferredFocusComponent + + override fun getName(): String = file.issue.displayNumber + + override fun getFile(): VirtualFile = file + + override fun setState(state: FileEditorState) = Unit + + override fun isModified(): Boolean = false + + override fun isValid(): Boolean = file.isValid + + override fun addPropertyChangeListener(listener: PropertyChangeListener) = listeners.addPropertyChangeListener(listener) + + override fun removePropertyChangeListener(listener: PropertyChangeListener) = listeners.removePropertyChangeListener(listener) + + override fun dispose() = panel.dispose() +} diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/editor/IssueFileEditorProvider.kt b/src/main/kotlin/com/github/vhrabar/issuehub/editor/IssueFileEditorProvider.kt new file mode 100644 index 0000000..e8cf5f8 --- /dev/null +++ b/src/main/kotlin/com/github/vhrabar/issuehub/editor/IssueFileEditorProvider.kt @@ -0,0 +1,34 @@ +package com.github.vhrabar.issuehub.editor + +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.fileEditor.FileEditorPolicy +import com.intellij.openapi.fileEditor.FileEditorProvider +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile + +/** + * Claims [IssueVirtualFile] for [IssueFileEditor]. + * + * [FileEditorPolicy.HIDE_DEFAULT_EDITOR] keeps the platform from offering its own view of the file's + * (empty) contents as a second tab next to ours. + */ +class IssueFileEditorProvider : + FileEditorProvider, + DumbAware { + override fun accept( + project: Project, + file: VirtualFile, + ): Boolean = file is IssueVirtualFile + + override fun acceptRequiresReadAction(): Boolean = false + + override fun createEditor( + project: Project, + file: VirtualFile, + ): FileEditor = IssueFileEditor(project, file as IssueVirtualFile) + + override fun getEditorTypeId(): String = "issuehub.issue" + + override fun getPolicy(): FileEditorPolicy = FileEditorPolicy.HIDE_DEFAULT_EDITOR +} diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/editor/IssueFileType.kt b/src/main/kotlin/com/github/vhrabar/issuehub/editor/IssueFileType.kt new file mode 100644 index 0000000..5bc18bb --- /dev/null +++ b/src/main/kotlin/com/github/vhrabar/issuehub/editor/IssueFileType.kt @@ -0,0 +1,25 @@ +package com.github.vhrabar.issuehub.editor + +import com.github.vhrabar.issuehub.IssueHubBundle +import com.intellij.openapi.fileTypes.FileType +import com.intellij.openapi.util.IconLoader +import javax.swing.Icon + +/** + * The type worn by [IssueVirtualFile]. + */ +internal object IssueFileType : FileType { + private val icon = IconLoader.getIcon("/icons/issueHub.svg", IssueFileType::class.java) + + override fun getName(): String = "IssueHub Issue" + + override fun getDescription(): String = IssueHubBundle["editor.fileType.description"] + + override fun getDefaultExtension(): String = "" + + override fun getIcon(): Icon = icon + + override fun isBinary(): Boolean = true + + override fun isReadOnly(): Boolean = true +} diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/editor/IssueVirtualFile.kt b/src/main/kotlin/com/github/vhrabar/issuehub/editor/IssueVirtualFile.kt new file mode 100644 index 0000000..7952b5d --- /dev/null +++ b/src/main/kotlin/com/github/vhrabar/issuehub/editor/IssueVirtualFile.kt @@ -0,0 +1,54 @@ +package com.github.vhrabar.issuehub.editor + +import com.github.vhrabar.issuehub.model.Issue +import com.github.vhrabar.issuehub.toolWindow.IssueStateIcon +import com.intellij.ide.FileIconProvider +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.testFramework.LightVirtualFile +import javax.swing.Icon + +/** + * An issue dressed up as a file, so the platform opens it in the editor area — full width, splittable + * and side by side with source — instead of inside the tool window. + * + * The file carries no content: [IssueFileEditor] draws the issue, and the panel re-reads it from the + * provider, so there is nothing here to keep in sync. + */ +internal class IssueVirtualFile( + val issue: Issue, +) : LightVirtualFile(issue.displayNumber, IssueFileType, "") { + init { + isWritable = false + } + + /** Editor tabs are titled from this; the full title lives in the panel header. */ + override fun getPresentableName(): String = issue.displayNumber + + companion object { + /** Opens [issue] in the editor area, focusing the tab it already has instead of stacking a second one. */ + fun open( + project: Project, + issue: Issue, + ) { + val manager = FileEditorManager.getInstance(project) + val open = manager.openFiles.firstOrNull { it is IssueVirtualFile && it.issue.id == issue.id } + manager.openFile(open ?: IssueVirtualFile(issue), true) + } + } +} + +/** Gives issue tabs the same state dot the list rows use, in place of the generic plugin icon. */ +class IssueFileIconProvider : FileIconProvider { + override fun getIcon( + file: VirtualFile, + flags: Int, + project: Project?, + ): Icon? = (file as? IssueVirtualFile)?.let { IssueStateIcon(it.issue.state, TAB_ICON_SIZE) } + + private companion object { + /** Editor tabs and the project view both budget 16px for a file icon. */ + const val TAB_ICON_SIZE = 16 + } +} diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/model/Issue.kt b/src/main/kotlin/com/github/vhrabar/issuehub/model/Issue.kt index 940d7b4..f8e4573 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/model/Issue.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/model/Issue.kt @@ -11,12 +11,18 @@ data class IssueLabel( /** * A milestone an issue can belong to. * - * [number] is the provider's own identifier + * [number] is the provider's own identifier, or [NUMBER_UNKNOWN] when the milestone was named + * by a source that doesn't publish one (history entries typically only carry the title). */ data class IssueMilestone( val number: Int, val title: String, -) +) { + companion object { + /** Providers number milestones from 1, so zero can't collide with a real one. */ + const val NUMBER_UNKNOWN = 0 + } +} /** * Generalized issue actor, author, an assignee, or the actor behind a @@ -50,10 +56,14 @@ data class Issue( * * [bodyHtml] is the description already rendered to HTML by the provider * It is null when the provider only hands back source text + * + * [timeline] is everything that happened after the description, oldest first. Empty when the + * provider can't serve a history, which is not the same as an issue nobody ever touched. */ data class IssueDetail( val issue: Issue, val bodyHtml: String? = null, + val timeline: List = emptyList(), ) /** diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubClient.kt b/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubClient.kt index eb0335b..ce13519 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubClient.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubClient.kt @@ -75,6 +75,41 @@ internal class GitHubClient( number: Int, ): URI = URI.create("$baseUrl/repos/${repo.owner}/${repo.name}/issues/$number") + /** + * Everything that happened to an issue after it was filed: comments, state changes, label and + * assignee edits. One endpoint covers all of them, again on the `full` media type so comment + * bodies arrive rendered. + * + * Pages are followed until one comes back short, up to [MAX_TIMELINE_PAGES]; a thread longer + * than that is truncated rather than allowed to spend an unbounded number of requests. + */ + suspend fun fetchTimeline( + repo: RepoCoordinates, + token: String?, + number: Int, + ): List = + buildList { + for (page in 1..MAX_TIMELINE_PAGES) { + val batch = + get(timelineUri(repo, number, page), token, ACCEPT_FULL) { + json.decodeFromString>(it) + } + addAll(batch) + if (batch.size < TIMELINE_PER_PAGE) break + } + } + + @VisibleForTesting + fun timelineUri( + repo: RepoCoordinates, + number: Int, + page: Int, + ): URI = + URI.create( + "$baseUrl/repos/${repo.owner}/${repo.name}/issues/$number/timeline" + + "?per_page=$TIMELINE_PER_PAGE&page=$page", + ) + suspend fun fetchLabels( repo: RepoCoordinates, token: String?, @@ -205,6 +240,11 @@ internal class GitHubClient( /** Filter dropdowns list every value at once; GitHub caps a page at 100. */ const val OPTIONS_PER_PAGE = 100 + const val TIMELINE_PER_PAGE = 100 + + /** 500 entries is far past what anyone scrolls, and bounds the requests one issue can cost. */ + const val MAX_TIMELINE_PAGES = 5 + fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8) /** Label names and milestone titles may contain spaces, which would split the search term. */ diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubDto.kt b/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubDto.kt index ffa508a..50f6c0e 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubDto.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubDto.kt @@ -52,3 +52,42 @@ internal data class GitHubIssueDto( ) { val isPullRequest: Boolean get() = pullRequest != null } + +@Serializable +internal data class GitHubTimelineMilestoneDto( + val title: String, +) + +@Serializable +internal data class GitHubRenameDto( + val from: String, + val to: String, +) + +@Serializable +internal data class GitHubTimelineSourceDto( + val issue: GitHubIssueDto? = null, +) + +/** + * One entry of `/issues/{n}/timeline`. + */ +@Serializable +internal data class GitHubTimelineEventDto( + val event: String? = null, + val actor: GitHubUserDto? = null, + val user: GitHubUserDto? = null, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("updated_at") val updatedAt: String? = null, + val body: String? = null, + @SerialName("body_html") val bodyHtml: String? = null, + @SerialName("html_url") val htmlUrl: String? = null, + val label: GitHubLabelDto? = null, + val assignee: GitHubUserDto? = null, + val milestone: GitHubTimelineMilestoneDto? = null, + val rename: GitHubRenameDto? = null, + val source: GitHubTimelineSourceDto? = null, + @SerialName("commit_id") val commitId: String? = null, + @SerialName("commit_url") val commitUrl: String? = null, + @SerialName("state_reason") val stateReason: String? = null, +) diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubIssueProvider.kt b/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubIssueProvider.kt index cacea9f..44f1341 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubIssueProvider.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubIssueProvider.kt @@ -8,6 +8,7 @@ import com.github.vhrabar.issuehub.model.IssueLabel import com.github.vhrabar.issuehub.model.IssueMilestone import com.github.vhrabar.issuehub.model.IssueQuery import com.github.vhrabar.issuehub.model.IssueState +import com.github.vhrabar.issuehub.model.IssueTimelineItem import com.github.vhrabar.issuehub.provider.IssueProvider import com.github.vhrabar.issuehub.settings.IssueHubSecrets import com.intellij.openapi.project.Project @@ -37,6 +38,84 @@ private fun GitHubIssueDto.toIssue(): Issue = updatedAt = updatedAt, ) +private val IGNORED_TIMELINE_EVENTS = setOf("subscribed", "unsubscribed", "mentioned") + +internal fun GitHubTimelineEventDto.toTimelineItem(): IssueTimelineItem? { + val at = createdAt ?: return null + val who = (actor ?: user)?.toActor() + return when (event) { + "commented" -> + IssueTimelineItem.Comment( + actor = who, + at = at, + body = body, + bodyHtml = bodyHtml, + url = htmlUrl, + edited = updatedAt != null && updatedAt != createdAt, + ) + + "closed" -> IssueTimelineItem.StateChange(who, at, IssueState.CLOSED, stateReason) + "reopened" -> IssueTimelineItem.StateChange(who, at, IssueState.OPEN) + + "labeled", "unlabeled" -> + label?.let { IssueTimelineItem.LabelChange(who, at, IssueLabel(it.name, it.color), added = event == "labeled") } + + "assigned", "unassigned" -> + assignee?.let { IssueTimelineItem.AssigneeChange(who, at, it.toActor(), added = event == "assigned") } + + "milestoned", "demilestoned" -> + milestone?.let { + IssueTimelineItem.MilestoneChange( + actor = who, + at = at, + milestone = IssueMilestone(IssueMilestone.NUMBER_UNKNOWN, it.title), + added = event == "milestoned", + ) + } + + "renamed" -> rename?.let { IssueTimelineItem.Renamed(who, at, it.from, it.to) } + + "cross-referenced" -> + source?.issue?.let { + IssueTimelineItem.CrossReferenced( + actor = who, + at = at, + displayNumber = "#${it.number}", + title = it.title, + url = it.htmlUrl, + isPullRequest = it.isPullRequest, + ) + } + + "referenced" -> commitId?.let { IssueTimelineItem.Referenced(who, at, it, commitUrl?.let(::commitWebUrl)) } + + null -> null + else -> IssueTimelineItem.Unknown(who, at, event) + } +} + +/** + * Turns the API address of a commit into the page a browser can actually show: + * `api.github.com/repos/OWNER/NAME/commits/SHA` is served as JSON, the commit people mean lives at + * `github.com/OWNER/NAME/commit/SHA`. + * + * The owner and name come from the URL itself rather than the repo we're looking at, because a + * commit that references an issue may well live in a fork. Enterprise installs put the API under + * `HOST/api/v3` instead of an `api.` host, so both spellings are undone. Null when the address + * isn't one we recognise, which leaves the entry showing a plain sha instead of a dead link. + */ +internal fun commitWebUrl(apiUrl: String): String? { + val base = apiUrl.substringBefore(API_REPOS_PATH, missingDelimiterValue = "").ifEmpty { return null } + val path = apiUrl.substringAfter(API_REPOS_PATH) + if (API_COMMITS_PATH !in path) return null + val host = base.removeSuffix("/api/v3").replace("://api.", "://") + return "$host/${path.replaceFirst(API_COMMITS_PATH, WEB_COMMIT_PATH)}" +} + +private const val API_REPOS_PATH = "/repos/" +private const val API_COMMITS_PATH = "/commits/" +private const val WEB_COMMIT_PATH = "/commit/" + class GitHubIssueProvider : IssueProvider { private var client = GitHubClient() @@ -57,7 +136,10 @@ class GitHubIssueProvider : IssueProvider { } /** - * Re-reads the issue rather than trusting the list row + * Re-reads the issue rather than trusting the list row, then the history behind it. + * + * The history is a second request and is allowed to fail on its own: a rate-limited or + * forbidden timeline shouldn't cost the user the description as well. */ override suspend fun fetchIssueDetail( project: Project, @@ -66,7 +148,12 @@ class GitHubIssueProvider : IssueProvider { val repo = RepoDetector.detect(project) ?: return null val token = IssueHubSecrets.getToken(identifier) val dto = client.fetchIssue(repo, token, issue.id) - return IssueDetail(issue = dto.toIssue(), bodyHtml = dto.bodyHtml) + val timeline = + runCatching { client.fetchTimeline(repo, token, issue.id) } + .getOrDefault(emptyList()) + .filterNot { it.event in IGNORED_TIMELINE_EVENTS } + .mapNotNull { it.toTimelineItem() } + return IssueDetail(issue = dto.toIssue(), bodyHtml = dto.bodyHtml, timeline = timeline) } /** From cc347f166294f574484467f621e635c2b76115a3 Mon Sep 17 00:00:00 2001 From: Vedran Hrabar Date: Sat, 1 Aug 2026 14:55:26 +0200 Subject: [PATCH 12/20] feat(issue timeline): wire issue detail view to UI #18 Signed-off-by: Vedran Hrabar --- .../issuehub/toolWindow/AvatarLoader.kt | 21 +- .../issuehub/toolWindow/IssueCellRenderer.kt | 4 +- .../issuehub/toolWindow/IssueDetailPanel.kt | 222 ++++++++++++++++ .../vhrabar/issuehub/toolWindow/IssueIcons.kt | 242 ++++++++++++++++-- .../issuehub/toolWindow/IssueThreadCard.kt | 49 ++++ .../issuehub/toolWindow/IssueThreadHtml.kt | 167 ++++++++++++ .../issuehub/toolWindow/IssueThreadPanel.kt | 205 +++++++++++++++ .../messages/IssueHubBundle.properties | 20 ++ 8 files changed, 903 insertions(+), 27 deletions(-) create mode 100644 src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueDetailPanel.kt create mode 100644 src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueThreadCard.kt create mode 100644 src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueThreadHtml.kt create mode 100644 src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueThreadPanel.kt diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/AvatarLoader.kt b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/AvatarLoader.kt index e694091..6fc1598 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/AvatarLoader.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/AvatarLoader.kt @@ -3,6 +3,7 @@ package com.github.vhrabar.issuehub.toolWindow import com.intellij.openapi.application.ApplicationManager import com.intellij.util.io.HttpRequests import com.intellij.util.ui.JBUI +import java.awt.Image import java.io.ByteArrayInputStream import java.util.concurrent.ConcurrentHashMap import javax.imageio.ImageIO @@ -17,16 +18,17 @@ import javax.swing.Icon internal class AvatarLoader( private val onLoaded: () -> Unit, ) { - private val cache = ConcurrentHashMap() + private val cache = ConcurrentHashMap() private val inFlight = ConcurrentHashMap.newKeySet() - /** The cached avatar for [url], or [fallback] while it loads (or if [url] is null). */ + /** The cached avatar for [url] at [size], or [fallback] while it loads (or if [url] is null). */ fun avatar( url: String?, fallback: Icon, + size: Int = CircularAvatarIcon.SIZE, ): Icon { if (url.isNullOrBlank()) return fallback - cache[url]?.let { return it } + cache[url]?.let { return CircularAvatarIcon(it, size) } scheduleLoad(url) return fallback } @@ -35,8 +37,8 @@ internal class AvatarLoader( if (!inFlight.add(url)) return ApplicationManager.getApplication().executeOnPooledThread { try { - runCatching { download(url) }.getOrNull()?.let { icon -> - cache[url] = icon + runCatching { download(url) }.getOrNull()?.let { image -> + cache[url] = image ApplicationManager.getApplication().invokeLater(onLoaded) } } finally { @@ -45,12 +47,11 @@ internal class AvatarLoader( } } - /** Fetches the avatar at device resolution and wraps it in a circular icon. */ - private fun download(url: String): Icon? { - val px = JBUI.scale(CircularAvatarIcon.SIZE) * 2 + /** Fetches the avatar at device resolution for the largest size anything draws it at. */ + private fun download(url: String): Image? { + val px = JBUI.scale(CircularAvatarIcon.MAX_SIZE) * 2 val sized = if ('?' in url) "$url&s=$px" else "$url?s=$px" val bytes = HttpRequests.request(sized).readBytes(null) - val image = ImageIO.read(ByteArrayInputStream(bytes)) ?: return null - return CircularAvatarIcon(image) + return ImageIO.read(ByteArrayInputStream(bytes)) } } diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueCellRenderer.kt b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueCellRenderer.kt index 565d9bc..1878bf8 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueCellRenderer.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueCellRenderer.kt @@ -127,7 +127,7 @@ internal class IssueCellRenderer( labelIcon.toolTipText = null return } - labelIcon.icon = IssueLabelIcon(labelTint(first, UIUtil.getListBackground(selected, hasFocus))) + labelIcon.icon = IssueLabelIcon(labelTint(first.color, UIUtil.getListBackground(selected, hasFocus))) labelIcon.toolTipText = labelsTooltip(value.labels) } @@ -141,7 +141,7 @@ internal class IssueCellRenderer( // A borderless table keeps the swatch and name vertically centered against each row's height. val rows = labels.joinToString("") { label -> - "${labelSwatch(labelTint(label, background))}" + + "${labelSwatch(labelTint(label.color, background))}" + " ${escapeHtml(label.name)}" } return "${escapeHtml(IssueHubBundle["issue.labels.title"])}" + diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueDetailPanel.kt b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueDetailPanel.kt new file mode 100644 index 0000000..3663a16 --- /dev/null +++ b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueDetailPanel.kt @@ -0,0 +1,222 @@ +package com.github.vhrabar.issuehub.toolWindow + +import com.github.vhrabar.issuehub.IssueHubBundle +import com.github.vhrabar.issuehub.model.Issue +import com.github.vhrabar.issuehub.model.IssueDetail +import com.github.vhrabar.issuehub.provider.IssueProvider +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBPanel +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.text.DateFormatUtil +import com.intellij.util.ui.JBFont +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.runBlocking +import java.awt.BorderLayout +import java.awt.CardLayout +import java.awt.FlowLayout +import java.time.Instant +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.ScrollPaneConstants + +/** + * The "main body" view of a single issue: title, metadata and the thread of activity below them. + * + * Opens against the list row it was launched from, so there is something on screen immediately, + * then replaces it with the provider's fuller answer once that arrives. + */ +internal class IssueDetailPanel( + private val project: Project, + private var issue: Issue, +) : JBPanel(BorderLayout()), + Disposable { + /** + * Re-renders rather than repaints: the byline and assignee hold whatever icon they were given, + * so a downloaded avatar only reaches the screen if we ask the loader for it again. + */ + private val avatarLoader = AvatarLoader(::renderHeader) + + private val title = + JBLabel().apply { + font = JBFont.label().biggerOn(2f).asBold() + icon = IssueStateIcon(issue.state) + iconTextGap = JBUI.scale(6) + } + private val byline = JBLabel().apply { foreground = UIUtil.getContextHelpForeground() } + private val metaRow = JBPanel>(FlowLayout(FlowLayout.LEFT, JBUI.scale(8), JBUI.scale(2))) + + /** Description and history, as one card per run of activity by the same account. */ + private val thread = IssueThreadPanel() + + private val statusLabel = JBLabel(IssueHubBundle["detail.loading"]) + private val cardLayout = CardLayout() + private val center = + JBPanel>(cardLayout).apply { + add( + JBPanel>(BorderLayout()).apply { + border = JBUI.Borders.empty(10) + add(statusLabel, BorderLayout.NORTH) + }, + STATUS_CARD, + ) + add( + JBScrollPane(thread).apply { + horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + border = JBUI.Borders.empty() + }, + BODY_CARD, + ) + } + + /** Guards against a slow reload landing after a newer one. */ + private var requestId = 0 + + /** The thread, so an editor host can hand it the focus and arrow keys scroll straight away. */ + internal val preferredFocusComponent: JComponent get() = thread + + init { + add(header(), BorderLayout.NORTH) + add(center, BorderLayout.CENTER) + renderHeader() + refresh() + } + + override fun dispose() = thread.dispose() + + private fun header(): JBPanel<*> = + JBPanel>(BorderLayout()).apply { + border = JBUI.Borders.empty(8, 10, 4, 10) + add( + JBPanel>(BorderLayout()).apply { + isOpaque = false + add(title, BorderLayout.CENTER) + add(actions(), BorderLayout.EAST) + }, + BorderLayout.NORTH, + ) + add( + JBPanel>(BorderLayout()).apply { + isOpaque = false + add(byline, BorderLayout.NORTH) + add(metaRow, BorderLayout.CENTER) + }, + BorderLayout.CENTER, + ) + } + + private fun actions(): JBPanel<*> = + JBPanel>(FlowLayout(FlowLayout.RIGHT, JBUI.scale(4), 0)).apply { + isOpaque = false + add( + JButton(IssueHubBundle["detail.openInBrowser"]).apply { + addActionListener { BrowserUtil.browse(issue.url) } + }, + ) + add( + JButton(IssueHubBundle["detail.refresh"]).apply { + addActionListener { refresh() } + }, + ) + } + + private fun renderHeader() { + title.icon = IssueStateIcon(issue.state) + title.text = issue.title + title.toolTipText = issue.title + + val created = runCatching { DateFormatUtil.formatDate(Instant.parse(issue.createdAt).toEpochMilli()) }.getOrNull() + byline.text = + IssueHubBundle[ + "detail.byline", + issue.displayNumber, + issue.author?.login ?: IssueHubBundle["detail.unknownAuthor"], + created ?: issue.createdAt, + issue.commentCount, + ] + byline.icon = issue.author?.let { avatarLoader.avatar(it.avatarUrl, IssueAvatarIcon(it.login)) } + byline.iconTextGap = JBUI.scale(6) + + renderMeta() + revalidate() + repaint() + } + + /** Labels, assignee and milestone as one wrapping row of chips. */ + private fun renderMeta() { + metaRow.removeAll() + metaRow.isOpaque = false + + issue.labels.forEach { label -> + metaRow.add( + JBLabel(label.name).apply { + icon = IssueLabelIcon(labelTint(label.color, UIUtil.getPanelBackground())) + iconTextGap = JBUI.scale(4) + }, + ) + } + metaRow.add( + issue.assignee.let { assignee -> + JBLabel(assignee?.let { IssueHubBundle["issue.assignedTo", it.login] } ?: IssueHubBundle["detail.unassigned"]).apply { + foreground = UIUtil.getContextHelpForeground() + icon = assignee?.let { avatarLoader.avatar(it.avatarUrl, IssueAvatarIcon(it.login)) } + iconTextGap = JBUI.scale(4) + } + }, + ) + metaRow.add( + JBLabel(issue.milestone?.title ?: IssueHubBundle["detail.noMilestone"]).apply { + foreground = UIUtil.getContextHelpForeground() + }, + ) + } + + /** + * The provider re-reads the issue because only that response carries the rendered description; + * a provider that can't serve details leaves us on the list row, which still has the source text. + */ + private fun refresh() { + val provider = IssueProvider.firstApplicable(project) + if (provider == null) { + showBody(null) + return + } + val id = ++requestId + statusLabel.text = IssueHubBundle["detail.loading"] + cardLayout.show(center, STATUS_CARD) + + ApplicationManager.getApplication().executeOnPooledThread { + val result = runCatching { runBlocking { provider.fetchIssueDetail(project, issue) } } + ApplicationManager.getApplication().invokeLater { + if (id != requestId) return@invokeLater + result + .onSuccess { detail -> + detail?.issue?.let { issue = it } + renderHeader() + showBody(detail) + }.onFailure { + statusLabel.text = IssueHubBundle["detail.error", it.message ?: it.toString()] + cardLayout.show(center, STATUS_CARD) + } + } + } + } + + /** + * The issue as cards. An issue with nothing in it still gets its opening card, which says so + * where the description would be, rather than replacing the whole view with a message. + */ + private fun showBody(detail: IssueDetail?) { + thread.show(issueThread(issue, detail)) + cardLayout.show(center, BODY_CARD) + } + + private companion object { + const val STATUS_CARD = "status" + const val BODY_CARD = "body" + } +} diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueIcons.kt b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueIcons.kt index 14405ba..006a972 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueIcons.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueIcons.kt @@ -5,15 +5,19 @@ import com.github.vhrabar.issuehub.model.IssueState import com.intellij.ui.ColorUtil import com.intellij.ui.JBColor import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil import java.awt.BasicStroke import java.awt.Color import java.awt.Component import java.awt.Graphics import java.awt.Graphics2D import java.awt.RenderingHints +import java.awt.geom.Arc2D import java.awt.geom.Area import java.awt.geom.Ellipse2D +import java.awt.geom.Line2D import java.awt.geom.Path2D +import java.awt.geom.RoundRectangle2D import javax.swing.Icon /** GitHub-ish state colors, tuned per popular theme so they stay legible on dark and on ligh omes. */ @@ -34,13 +38,211 @@ private val NEUTRAL_LABEL = JBColor(Color(0x9AA7B0), Color(0x6C707E)) /** GitHub picks label colors against a white page, so lift them when the surface is dark. */ internal fun labelTint( - label: IssueLabel, + color: String?, background: Color, ): Color { - val base = label.color?.let { ColorUtil.fromHex(it, null) } ?: NEUTRAL_LABEL + val base = color?.let { ColorUtil.fromHex(it, null) } ?: NEUTRAL_LABEL return if (ColorUtil.isDark(background)) ColorUtil.brighter(base, 1) else base } +/** + * The icons the thread puts in front of its history lines. + * + * An HTML pane can't be handed a painted [Icon], only a string it resolves through a callback, so + * every icon needs a name — including the label pennant, whose colour rides along in its key. + */ +internal object ThreadIcons { + const val COMMENT = "comment" + const val OPENED = "opened" + const val CLOSED = "closed" + const val REOPENED = "reopened" + const val ASSIGNEE = "assignee" + const val MILESTONE = "milestone" + const val RENAME = "rename" + const val REFERENCE = "reference" + const val COMMIT = "commit" + const val OTHER = "other" + + private const val LABEL_PREFIX = "label:" + + /** Keyed by colour, so the pennant in the history matches the chip in the header. */ + fun label(label: IssueLabel): String = LABEL_PREFIX + label.color.orEmpty() + + /** Null for a name we don't know, which the pane renders as nothing at all. */ + fun resolve(key: String): Icon? = + when (key) { + COMMENT -> IssueEventIcon(IssueEventIcon.Kind.COMMENT) + OPENED, REOPENED -> IssueStateIcon(IssueState.OPEN) + CLOSED -> IssueStateIcon(IssueState.CLOSED) + ASSIGNEE -> IssueEventIcon(IssueEventIcon.Kind.ASSIGNEE) + MILESTONE -> IssueEventIcon(IssueEventIcon.Kind.MILESTONE) + RENAME -> IssueEventIcon(IssueEventIcon.Kind.RENAME) + REFERENCE -> IssueEventIcon(IssueEventIcon.Kind.REFERENCE) + COMMIT -> IssueEventIcon(IssueEventIcon.Kind.COMMIT) + OTHER -> IssueEventIcon(IssueEventIcon.Kind.OTHER) + else -> + key + .takeIf { it.startsWith(LABEL_PREFIX) } + ?.let { IssueLabelIcon(labelTint(it.removePrefix(LABEL_PREFIX).ifEmpty { null }, UIUtil.getPanelBackground())) } + } +} + +/** + * The glyphs in front of the thread's history lines, in the same muted grey as the text they + * introduce. + * + * Drawn rather than loaded: a platform icon resolves its image lazily and leaves the line blank + * until something repaints it, and a thread is painted once and then sits still. + */ +internal class IssueEventIcon( + private val kind: Kind, + private val size: Int = SIZE, +) : Icon { + enum class Kind { COMMENT, ASSIGNEE, MILESTONE, RENAME, REFERENCE, COMMIT, OTHER } + + override fun getIconWidth(): Int = JBUI.scale(size) + + override fun getIconHeight(): Int = JBUI.scale(size) + + override fun paintIcon( + c: Component?, + g: Graphics, + x: Int, + y: Int, + ) { + val g2 = g.create() as Graphics2D + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + val s = JBUI.scale(size).toFloat() + g2.color = UIUtil.getContextHelpForeground() + g2.stroke = BasicStroke(JBUI.scale(1).toFloat() * STROKE, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND) + when (kind) { + Kind.COMMENT -> comment(g2, x, y, s) + Kind.ASSIGNEE -> assignee(g2, x, y, s) + Kind.MILESTONE -> milestone(g2, x, y, s) + Kind.RENAME -> rename(g2, x, y, s) + Kind.REFERENCE -> reference(g2, x, y, s) + Kind.COMMIT -> commit(g2, x, y, s) + Kind.OTHER -> other(g2, x, y, s) + } + } finally { + g2.dispose() + } + } + + /** A speech balloon with a tail hanging off its bottom-left corner. */ + private fun comment( + g2: Graphics2D, + x: Int, + y: Int, + s: Float, + ) { + g2.fill(RoundRectangle2D.Float(x + 0.08f * s, y + 0.14f * s, 0.84f * s, 0.58f * s, 0.34f * s, 0.34f * s)) + g2.fill( + Path2D.Float().apply { + moveTo(x + 0.26f * s, y + 0.62f * s) + lineTo(x + 0.26f * s, y + 0.92f * s) + lineTo(x + 0.52f * s, y + 0.66f * s) + closePath() + }, + ) + } + + /** A head over shoulders. */ + private fun assignee( + g2: Graphics2D, + x: Int, + y: Int, + s: Float, + ) { + g2.fill(Ellipse2D.Float(x + 0.31f * s, y + 0.10f * s, 0.38f * s, 0.38f * s)) + g2.fill(Arc2D.Float(x + 0.12f * s, y + 0.54f * s, 0.76f * s, 0.62f * s, 0f, 180f, Arc2D.CHORD)) + } + + /** A pennant on a pole. */ + private fun milestone( + g2: Graphics2D, + x: Int, + y: Int, + s: Float, + ) { + g2.draw(Line2D.Float(x + 0.26f * s, y + 0.08f * s, x + 0.26f * s, y + 0.94f * s)) + g2.fill( + Path2D.Float().apply { + moveTo(x + 0.30f * s, y + 0.13f * s) + lineTo(x + 0.88f * s, y + 0.32f * s) + lineTo(x + 0.30f * s, y + 0.51f * s) + closePath() + }, + ) + } + + /** A pencil lying across the box. */ + private fun rename( + g2: Graphics2D, + x: Int, + y: Int, + s: Float, + ) { + g2.fill( + Path2D.Float().apply { + moveTo(x + 0.10f * s, y + 0.90f * s) + lineTo(x + 0.24f * s, y + 0.60f * s) + lineTo(x + 0.68f * s, y + 0.16f * s) + lineTo(x + 0.86f * s, y + 0.34f * s) + lineTo(x + 0.42f * s, y + 0.78f * s) + closePath() + }, + ) + } + + /** An arrow leaving the corner, for a reference that points somewhere else. */ + private fun reference( + g2: Graphics2D, + x: Int, + y: Int, + s: Float, + ) { + g2.draw(Line2D.Float(x + 0.16f * s, y + 0.84f * s, x + 0.72f * s, y + 0.28f * s)) + g2.fill( + Path2D.Float().apply { + moveTo(x + 0.88f * s, y + 0.12f * s) + lineTo(x + 0.88f * s, y + 0.56f * s) + lineTo(x + 0.44f * s, y + 0.12f * s) + closePath() + }, + ) + } + + /** A node on a branch line, the way a commit is drawn in a history graph. */ + private fun commit( + g2: Graphics2D, + x: Int, + y: Int, + s: Float, + ) { + g2.draw(Line2D.Float(x + 0.02f * s, y + 0.5f * s, x + 0.24f * s, y + 0.5f * s)) + g2.draw(Line2D.Float(x + 0.76f * s, y + 0.5f * s, x + 0.98f * s, y + 0.5f * s)) + g2.draw(Ellipse2D.Float(x + 0.26f * s, y + 0.26f * s, 0.48f * s, 0.48f * s)) + } + + /** An ellipsis, for something that happened that we have no better glyph for. */ + private fun other( + g2: Graphics2D, + x: Int, + y: Int, + s: Float, + ) { + val d = 0.2f * s + listOf(0.12f, 0.4f, 0.68f).forEach { g2.fill(Ellipse2D.Float(x + it * s, y + 0.4f * s, d, d)) } + } + + private companion object { + const val SIZE = 13 + const val STROKE = 1.2f + } +} + /** * A filled circle marking issue state, sized to sit on the title baseline. * @@ -130,13 +332,18 @@ internal class IssueLabelIcon( } } -/** A downloaded avatar image, clipped to a circle so it matches the initials fallback. */ +/** + * A downloaded avatar image, clipped to a circle so it matches the initials fallback. + * + * [size] is the box it is drawn in: the default suits a list row, a thread card asks for more. + */ internal class CircularAvatarIcon( private val image: java.awt.Image, + private val size: Int = SIZE, ) : Icon { - override fun getIconWidth(): Int = JBUI.scale(SIZE) + override fun getIconWidth(): Int = JBUI.scale(size) - override fun getIconHeight(): Int = JBUI.scale(SIZE) + override fun getIconHeight(): Int = JBUI.scale(size) override fun paintIcon( c: Component?, @@ -148,9 +355,9 @@ internal class CircularAvatarIcon( try { g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR) - val size = JBUI.scale(SIZE) - g2.clip = Ellipse2D.Double(x.toDouble(), y.toDouble(), size.toDouble(), size.toDouble()) - g2.drawImage(image, x, y, size, size, null) + val box = JBUI.scale(size) + g2.clip = Ellipse2D.Double(x.toDouble(), y.toDouble(), box.toDouble(), box.toDouble()) + g2.drawImage(image, x, y, box, box, null) } finally { g2.dispose() } @@ -158,6 +365,9 @@ internal class CircularAvatarIcon( companion object { const val SIZE = 16 + + /** The largest an avatar is ever drawn, which sets the resolution worth downloading. */ + const val MAX_SIZE = 24 } } @@ -167,13 +377,14 @@ internal class CircularAvatarIcon( */ internal class IssueAvatarIcon( login: String, + private val size: Int = SIZE, ) : Icon { private val initial = login.firstOrNull { it.isLetterOrDigit() }?.uppercaseChar() ?: '?' private val background = PALETTE[Math.floorMod(login.hashCode(), PALETTE.size)] - override fun getIconWidth(): Int = JBUI.scale(SIZE) + override fun getIconWidth(): Int = JBUI.scale(size) - override fun getIconHeight(): Int = JBUI.scale(SIZE) + override fun getIconHeight(): Int = JBUI.scale(size) override fun paintIcon( c: Component?, @@ -186,18 +397,19 @@ internal class IssueAvatarIcon( g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON) - val size = JBUI.scale(SIZE) + val box = JBUI.scale(size) g2.color = background - g2.fill(Ellipse2D.Double(x.toDouble(), y.toDouble(), size.toDouble(), size.toDouble())) + g2.fill(Ellipse2D.Double(x.toDouble(), y.toDouble(), box.toDouble(), box.toDouble())) g2.color = JBColor.WHITE - g2.font = JBUI.Fonts.label(FONT_SIZE).asBold() + // The initial keeps its share of the circle however large the circle is asked to be. + g2.font = JBUI.Fonts.label(FONT_SIZE * size / SIZE).asBold() val text = initial.toString() val metrics = g2.fontMetrics g2.drawString( text, - x + (size - metrics.stringWidth(text)) / 2f, - y + (size - metrics.height) / 2f + metrics.ascent, + x + (box - metrics.stringWidth(text)) / 2f, + y + (box - metrics.height) / 2f + metrics.ascent, ) } finally { g2.dispose() diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueThreadCard.kt b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueThreadCard.kt new file mode 100644 index 0000000..bc0b5fb --- /dev/null +++ b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueThreadCard.kt @@ -0,0 +1,49 @@ +package com.github.vhrabar.issuehub.toolWindow + +import com.github.vhrabar.issuehub.model.Issue +import com.github.vhrabar.issuehub.model.IssueActor +import com.github.vhrabar.issuehub.model.IssueDetail +import com.github.vhrabar.issuehub.model.IssueTimelineItem + +/** + * One card of the issue view: an unbroken run of history entries by the same account. + * + * Grouping is what keeps a long thread readable — a bot that stamps six labels in a row costs one + * card, not six — and it is why the account is named once, by the card, instead of on every line. + */ +internal data class IssueThreadCard( + val actor: IssueActor?, + val items: List, + /** True for the card the issue opens with, whose first entry is the description itself. */ + val opensTheIssue: Boolean = false, +) + +/** + * The issue as a thread of cards: the description is the opening comment, the history follows. + * + * Runs are broken by author and never reordered, so a card always covers one continuous stretch + * of the issue's life rather than everything one account ever did to it. + */ +internal fun issueThread( + issue: Issue, + detail: IssueDetail?, +): List { + val opening = + IssueTimelineItem.Comment( + actor = issue.author, + at = issue.createdAt, + body = issue.body, + bodyHtml = detail?.bodyHtml, + url = issue.url, + ) + val cards = mutableListOf() + for (item in listOf(opening) + detail?.timeline.orEmpty()) { + val last = cards.lastOrNull() + if (last != null && last.actor?.login == item.actor?.login) { + cards[cards.lastIndex] = last.copy(items = last.items + item) + } else { + cards += IssueThreadCard(item.actor, listOf(item), opensTheIssue = cards.isEmpty()) + } + } + return cards +} diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueThreadHtml.kt b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueThreadHtml.kt new file mode 100644 index 0000000..6928ba2 --- /dev/null +++ b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueThreadHtml.kt @@ -0,0 +1,167 @@ +package com.github.vhrabar.issuehub.toolWindow + +import com.github.vhrabar.issuehub.IssueHubBundle +import com.github.vhrabar.issuehub.model.IssueState +import com.github.vhrabar.issuehub.model.IssueTimelineItem +import com.intellij.util.text.DateFormatUtil +import java.time.Instant + +/** + * The contents of one thread card as an HTML document, for the platform's own HTML pane. + * + * Nothing here names the account: the card puts that beside the text, so the lines read as + * "commented on 3 Jul" rather than repeating a login the reader already has in view. + * + * [muted] is a CSS colour for everything that isn't the text people wrote themselves. + */ +internal fun cardContentHtml( + card: IssueThreadCard, + muted: String, +): String = + card.items + .mapIndexed { index, item -> item.toHtml(muted, opening = card.opensTheIssue && index == 0) } + .joinToString(separator = "\n", prefix = "", postfix = "") + +private fun IssueTimelineItem.toHtml( + muted: String, + opening: Boolean, +): String { + val on = formatAt(at) + return when (this) { + is IssueTimelineItem.Comment -> commentHtml(on, muted, opening) + + is IssueTimelineItem.StateChange -> + event( + if (state == IssueState.CLOSED) ThreadIcons.CLOSED else ThreadIcons.REOPENED, + when { + state != IssueState.CLOSED -> IssueHubBundle["detail.timeline.reopened", on] + reason.isNullOrBlank() -> IssueHubBundle["detail.timeline.closed", on] + else -> IssueHubBundle["detail.timeline.closedAs", on, humanize(reason)] + }, + muted, + ) + + is IssueTimelineItem.LabelChange -> + event( + ThreadIcons.label(label), + if (added) { + IssueHubBundle["detail.timeline.labeled", on, escapeHtml(label.name)] + } else { + IssueHubBundle["detail.timeline.unlabeled", on, escapeHtml(label.name)] + }, + muted, + ) + + is IssueTimelineItem.AssigneeChange -> + event( + ThreadIcons.ASSIGNEE, + if (added) { + IssueHubBundle["detail.timeline.assigned", on, escapeHtml(assignee.login)] + } else { + IssueHubBundle["detail.timeline.unassigned", on, escapeHtml(assignee.login)] + }, + muted, + ) + + is IssueTimelineItem.MilestoneChange -> + event( + ThreadIcons.MILESTONE, + if (added) { + IssueHubBundle["detail.timeline.milestoned", on, escapeHtml(milestone.title)] + } else { + IssueHubBundle["detail.timeline.demilestoned", on, escapeHtml(milestone.title)] + }, + muted, + ) + + is IssueTimelineItem.Renamed -> + event( + ThreadIcons.RENAME, + IssueHubBundle["detail.timeline.renamed", on, escapeHtml(from), escapeHtml(to)], + muted, + ) + + is IssueTimelineItem.CrossReferenced -> + event( + ThreadIcons.REFERENCE, + IssueHubBundle[ + "detail.timeline.crossReferenced", + on, + link(url, "${escapeHtml(displayNumber)} ${escapeHtml(title)}"), + ], + muted, + ) + + is IssueTimelineItem.Referenced -> + event( + ThreadIcons.COMMIT, + IssueHubBundle[ + "detail.timeline.referenced", + on, + commitUrl?.let { link(it, escapeHtml(shortSha)) } ?: escapeHtml(shortSha), + ], + muted, + ) + + is IssueTimelineItem.Unknown -> + event(ThreadIcons.OTHER, IssueHubBundle["detail.timeline.other", on, humanize(kind)], muted) + } +} + +/** A muted "commented on …" line, then the comment itself in whatever form the provider sent. */ +private fun IssueTimelineItem.Comment.commentHtml( + on: String, + muted: String, + opening: Boolean, +): String { + val header = + if (opening) IssueHubBundle["detail.timeline.opened", on] else IssueHubBundle["detail.timeline.commented", on] + val icon = if (opening) ThreadIcons.OPENED else ThreadIcons.COMMENT + val edit = if (edited) " · ${IssueHubBundle["detail.timeline.edited"]}" else "" + val rendered = + bodyHtml?.takeIf { it.isNotBlank() } + // Without the detail request all we have is the Markdown source, which goes out as-is. + ?: body?.takeIf { it.isNotBlank() }?.let { "
${escapeHtml(it)}
" } + ?: muted( + if (opening) IssueHubBundle["detail.noDescription"] else IssueHubBundle["detail.timeline.emptyComment"], + muted, + ) + // Indented to start under its own header rather than out beside the icons. + return event(icon, header + edit, muted) + """
$rendered
""" +} + +/** A history line: its icon, then the muted sentence describing what happened. */ +private fun event( + icon: String, + text: String, + muted: String, +): String = muted(""" $text""", muted) + +/** Roughly an icon plus its trailing space, so text lines up whether or not it carries one. */ +private const val ICON_INDENT = 20 + +private val IssueTimelineItem.Referenced.shortSha: String get() = commitSha.take(SHORT_SHA_LENGTH) + +private const val SHORT_SHA_LENGTH = 7 + +private fun muted( + text: String, + color: String, +): String = """
$text
""" + +private fun link( + url: String, + text: String, +): String = """$text""" + +/** Providers name their event kinds and close reasons in snake or kebab case. */ +private fun humanize(value: String): String = escapeHtml(value.replace('_', ' ').replace('-', ' ')) + +/** Falls back to the raw stamp when a provider hands back something we can't parse. */ +private fun formatAt(at: String): String = runCatching { DateFormatUtil.formatDate(Instant.parse(at).toEpochMilli()) }.getOrDefault(at) + +internal fun escapeHtml(text: String): String = + text + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueThreadPanel.kt b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueThreadPanel.kt new file mode 100644 index 0000000..4dd1a6c --- /dev/null +++ b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueThreadPanel.kt @@ -0,0 +1,205 @@ +package com.github.vhrabar.issuehub.toolWindow + +import com.github.vhrabar.issuehub.IssueHubBundle +import com.github.vhrabar.issuehub.model.IssueActor +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.Disposable +import com.intellij.ui.ColorUtil +import com.intellij.ui.JBColor +import com.intellij.ui.RoundedLineBorder +import com.intellij.ui.components.JBHtmlPane +import com.intellij.ui.components.JBHtmlPaneConfiguration +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBPanel +import com.intellij.ui.components.panels.VerticalLayout +import com.intellij.util.ui.JBFont +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.BorderLayout +import java.awt.Dimension +import java.awt.Rectangle +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent +import javax.swing.JComponent +import javax.swing.Scrollable +import javax.swing.SwingConstants +import javax.swing.SwingUtilities +import javax.swing.event.HyperlinkEvent + +/** + * The issue as a stack of cards, one per run of history by the same account: the author's avatar + * and name down the left, what they did and wrote to the right of it. + * + * Cards are Swing components rather than one big HTML document because the avatars are painted + * icons, not images an HTML pane could fetch; each card still renders its own text through the + * platform's HTML pane, which themes GitHub's markup to match the IDE. + */ +internal class IssueThreadPanel : + JBPanel(VerticalLayout(JBUI.scale(CARD_GAP))), + Scrollable, + Disposable { + private val avatarLoader = AvatarLoader(::applyAvatars) + + /** Held so they can be disposed: an HTML pane owns platform resources the GC won't reclaim. */ + private val panes = mutableListOf() + + /** + * Held so a downloaded avatar can replace the initials standing in for it. A repaint alone + * wouldn't: the label keeps whatever icon it was given, so the picture would only turn up the + * next time the thread was rebuilt. + */ + private val avatars = mutableListOf>() + + init { + border = JBUI.Borders.empty(CARD_GAP) + isOpaque = false + // Focusable so the scroll pane's own arrow-key bindings work the moment the tab opens. + isFocusable = true + addComponentListener( + object : ComponentAdapter() { + override fun componentResized(event: ComponentEvent) = remeasure() + }, + ) + } + + fun show(cards: List) { + disposePanes() + avatars.clear() + removeAll() + val muted = ColorUtil.toHtmlColor(UIUtil.getContextHelpForeground()) + cards.forEach { add(card(it, muted)) } + revalidate() + repaint() + // Fresh cards start out with no width, so their first measurement is meaningless. Reloading + // an issue leaves the panel the same size, so nothing else would ask them to measure again. + SwingUtilities.invokeLater(::remeasure) + } + + /** A pane only knows how tall its text is once it knows how wide it has been given. */ + private fun remeasure() { + panes.forEach { it.invalidate() } + revalidate() + } + + /** Re-asks for every avatar, which is how a download that has just landed reaches the screen. */ + private fun applyAvatars() { + avatars.forEach { (label, actor) -> + label.icon = avatar(actor, label.text) + } + repaint() + } + + private fun avatar( + actor: IssueActor?, + login: String, + ) = avatarLoader.avatar(actor?.avatarUrl, IssueAvatarIcon(login, AVATAR_SIZE), AVATAR_SIZE) + + override fun dispose() = disposePanes() + + private fun disposePanes() { + panes.forEach { it.dispose() } + panes.clear() + } + + private fun card( + card: IssueThreadCard, + muted: String, + ): JComponent { + val content = + CardContentPane().apply { + isEditable = false + isOpaque = false + border = JBUI.Borders.empty() + // GitHub renders every reference as an absolute URL, so there is no base to resolve against. + addHyperlinkListener { event -> + if (event.eventType == HyperlinkEvent.EventType.ACTIVATED) { + event.url?.let { BrowserUtil.browse(it) } + } + } + text = cardContentHtml(card, muted) + } + panes += content + + return JBPanel>(BorderLayout(JBUI.scale(CARD_GAP), 0)).apply { + isOpaque = false + border = + JBUI.Borders.compound( + RoundedLineBorder(JBColor.border(), JBUI.scale(CARD_ARC), 1), + JBUI.Borders.empty(CARD_PADDING), + ) + add(author(card), BorderLayout.WEST) + add(content, BorderLayout.CENTER) + } + } + + /** + * Avatar and login, in a column of fixed width so every card's text starts on the same line + * however long the account names happen to be. + */ + private fun author(card: IssueThreadCard): JComponent { + val login = card.actor?.login ?: IssueHubBundle["detail.unknownAuthor"] + val label = + JBLabel(login).apply { + font = JBFont.label().asBold() + iconTextGap = JBUI.scale(6) + toolTipText = login + } + avatars += label to card.actor + label.icon = avatar(card.actor, login) + return JBPanel>(BorderLayout()).apply { + isOpaque = false + preferredSize = Dimension(JBUI.scale(AUTHOR_WIDTH), 0) + minimumSize = preferredSize + // Pinned to the top so a long comment doesn't push its author into the middle of the card. + add(label, BorderLayout.NORTH) + } + } + + override fun getPreferredScrollableViewportSize(): Dimension = preferredSize + + override fun getScrollableUnitIncrement( + visibleRect: Rectangle, + orientation: Int, + direction: Int, + ): Int = JBUI.scale(SCROLL_UNIT) + + override fun getScrollableBlockIncrement( + visibleRect: Rectangle, + orientation: Int, + direction: Int, + ): Int = if (orientation == SwingConstants.VERTICAL) visibleRect.height else visibleRect.width + + /** Take the viewport's width rather than the widest line's, so the cards wrap instead of clipping. */ + override fun getScrollableTracksViewportWidth(): Boolean = true + + override fun getScrollableTracksViewportHeight(): Boolean = false + + /** + * A pane that reports the height its text needs at the width it was given. + * + * A stacked layout asks how big a component wants to be before it hands out any width, and a + * text pane left to itself answers with the width of its longest unwrapped line — which would + * drag the card off the right edge instead of wrapping inside it. + */ + private class CardContentPane : JBHtmlPane() { + /** The pane resolves `` through this, since it can't be handed a painted icon. */ + override fun initializePaneConfiguration(builder: JBHtmlPaneConfiguration.Builder) { + super.initializePaneConfiguration(builder) + builder.iconResolver(ThreadIcons::resolve) + } + + override fun getPreferredSize(): Dimension { + val preferred = super.getPreferredSize() + return if (width > 0) Dimension(width, preferred.height) else preferred + } + } + + private companion object { + const val CARD_GAP = 8 + const val CARD_PADDING = 10 + const val CARD_ARC = 10 + val AVATAR_SIZE = CircularAvatarIcon.MAX_SIZE + const val AUTHOR_WIDTH = 150 + const val SCROLL_UNIT = 16 + } +} diff --git a/src/main/resources/messages/IssueHubBundle.properties b/src/main/resources/messages/IssueHubBundle.properties index 6dcb567..3602bd4 100644 --- a/src/main/resources/messages/IssueHubBundle.properties +++ b/src/main/resources/messages/IssueHubBundle.properties @@ -47,6 +47,26 @@ detail.unknownAuthor=someone detail.unassigned=Unassigned detail.noMilestone=No milestone +# Timeline entries. The card names the account, so these lines don't: {0} is when it happened, +# anything after that is specific to the entry. +detail.timeline.opened=opened this on {0} +detail.timeline.commented=commented on {0} +detail.timeline.edited=edited +detail.timeline.emptyComment=Empty comment. +detail.timeline.closed=closed this on {0} +detail.timeline.closedAs=closed this as {1} on {0} +detail.timeline.reopened=reopened this on {0} +detail.timeline.labeled=added the {1} label on {0} +detail.timeline.unlabeled=removed the {1} label on {0} +detail.timeline.assigned=assigned {1} on {0} +detail.timeline.unassigned=unassigned {1} on {0} +detail.timeline.milestoned=added this to the {1} milestone on {0} +detail.timeline.demilestoned=removed this from the {1} milestone on {0} +detail.timeline.renamed=changed the title from "{1}" to "{2}" on {0} +detail.timeline.crossReferenced=referenced this in {1} on {0} +detail.timeline.referenced=referenced this in commit {1} on {0} +detail.timeline.other={1} this on {0} + issue.state.open=OPEN issue.state.closed=CLOSED issue.state.other= From af0969386139f0e8b8b16ffcb298c6dc7721bd3d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 04:36:44 +0000 Subject: [PATCH 13/20] chore(deps): bump the actions group with 2 updates Bumps the actions group with 2 updates: [gradle/actions](https://github.com/gradle/actions) and [JetBrains/qodana-action](https://github.com/jetbrains/qodana-action). Updates `gradle/actions` from 6 to 6.2.0 - [Release notes](https://github.com/gradle/actions/releases) - [Commits](https://github.com/gradle/actions/compare/v6...v6.2.0) Updates `JetBrains/qodana-action` from 2026.1 to 2026.2 - [Release notes](https://github.com/jetbrains/qodana-action/releases) - [Commits](https://github.com/jetbrains/qodana-action/compare/v2026.1...v2026.2) --- updated-dependencies: - dependency-name: gradle/actions dependency-version: 6.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions - dependency-name: JetBrains/qodana-action dependency-version: '2026.2' dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions ... Signed-off-by: dependabot[bot] --- .github/workflows/build.yml | 8 ++++---- .github/workflows/qodana.yml | 2 +- .github/workflows/release.yml | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 245fa02..a9b6d11 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -50,7 +50,7 @@ jobs: # Setup Gradle - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@v6.2.0 # Build plugin - name: Build plugin @@ -90,7 +90,7 @@ jobs: # Setup Gradle - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@v6.2.0 with: cache-read-only: true @@ -133,7 +133,7 @@ jobs: # Setup Gradle - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@v6.2.0 with: cache-read-only: true @@ -173,7 +173,7 @@ jobs: # Setup Gradle - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@v6.2.0 with: cache-read-only: true diff --git a/.github/workflows/qodana.yml b/.github/workflows/qodana.yml index 6f290f0..00ad9b9 100644 --- a/.github/workflows/qodana.yml +++ b/.github/workflows/qodana.yml @@ -38,7 +38,7 @@ jobs: # Run Qodana using the configuration from qodana.yml - name: Qodana Scan - uses: JetBrains/qodana-action@v2026.1 + uses: JetBrains/qodana-action@v2026.2 with: pr-mode: false env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 188ce41..e50658b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,7 +43,7 @@ jobs: # Setup Gradle - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 + uses: gradle/actions/setup-gradle@v6.2.0 with: cache-read-only: true From 0d0e818d04e420e62cf27628c447fb86fbf67690 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 04:40:08 +0000 Subject: [PATCH 14/20] chore(deps): bump the gradle group across 1 directory with 4 updates Bumps the gradle group with 4 updates in the / directory: [org.jetbrains.kotlinx:kotlinx-serialization-json](https://github.com/Kotlin/kotlinx.serialization), [org.jetbrains.kotlin.jvm](https://github.com/JetBrains/kotlin), [org.jetbrains.kotlin.plugin.serialization](https://github.com/JetBrains/kotlin) and com.diffplug.spotless. Updates `org.jetbrains.kotlinx:kotlinx-serialization-json` from 1.7.3 to 1.11.0 - [Release notes](https://github.com/Kotlin/kotlinx.serialization/releases) - [Changelog](https://github.com/Kotlin/kotlinx.serialization/blob/master/CHANGELOG.md) - [Commits](https://github.com/Kotlin/kotlinx.serialization/compare/v1.7.3...v1.11.0) Updates `org.jetbrains.kotlin.jvm` from 2.1.20 to 2.4.10 - [Release notes](https://github.com/JetBrains/kotlin/releases) - [Changelog](https://github.com/JetBrains/kotlin/blob/master/ChangeLog.md) - [Commits](https://github.com/JetBrains/kotlin/compare/v2.1.20...v2.4.10) Updates `org.jetbrains.kotlin.plugin.serialization` from 2.1.20 to 2.4.10 - [Release notes](https://github.com/JetBrains/kotlin/releases) - [Changelog](https://github.com/JetBrains/kotlin/blob/master/ChangeLog.md) - [Commits](https://github.com/JetBrains/kotlin/compare/v2.1.20...v2.4.10) Updates `com.diffplug.spotless` from 7.0.2 to 8.9.0 --- updated-dependencies: - dependency-name: com.diffplug.spotless dependency-version: 8.8.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: gradle - dependency-name: org.jetbrains.kotlin.jvm dependency-version: 2.4.10 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: gradle - dependency-name: org.jetbrains.kotlin.plugin.serialization dependency-version: 2.4.10 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: gradle - dependency-name: org.jetbrains.kotlinx:kotlinx-serialization-json dependency-version: 1.11.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: gradle ... Signed-off-by: dependabot[bot] --- build.gradle.kts | 2 +- settings.gradle.kts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 2fd9290..0b6293e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,7 +10,7 @@ plugins { } dependencies { - compileOnly("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + compileOnly("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0") testImplementation("junit:junit:4.13.2") // IntelliJ Platform Gradle Plugin Dependencies Extension - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-dependencies-extension.html diff --git a/settings.gradle.kts b/settings.gradle.kts index ee75b0f..92c28f4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,10 +4,10 @@ rootProject.name = "IssueHub" pluginManagement { plugins { - id("org.jetbrains.kotlin.jvm") version "2.1.20" - id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20" + id("org.jetbrains.kotlin.jvm") version "2.4.10" + id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10" id("org.jetbrains.changelog") version "2.5.0" - id("com.diffplug.spotless") version "7.0.2" + id("com.diffplug.spotless") version "8.9.0" } } From cb6c0a3376ef7a30d274ef4e5baa680424c7386b Mon Sep 17 00:00:00 2001 From: Vedran Hrabar Date: Tue, 4 Aug 2026 12:19:01 +0200 Subject: [PATCH 15/20] test: add unit tests for GitHub API client and issue filtering Signed-off-by: Vedran Hrabar --- .../issuehub/model/IssueFilterOptionsTest.kt | 73 +++++ .../provider/github/CommitWebUrlTest.kt | 40 +++ .../provider/github/GitHubClientUriTest.kt | 108 ++++++++ .../provider/github/GitHubIssueDtoTest.kt | 45 +++ .../provider/github/GitHubTimelineDtoTest.kt | 259 ++++++++++++++++++ .../issuehub/toolWindow/IssueThreadTest.kt | 108 ++++++++ 6 files changed, 633 insertions(+) create mode 100644 src/test/kotlin/com/github/vhrabar/issuehub/model/IssueFilterOptionsTest.kt create mode 100644 src/test/kotlin/com/github/vhrabar/issuehub/provider/github/CommitWebUrlTest.kt create mode 100644 src/test/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubClientUriTest.kt create mode 100644 src/test/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubIssueDtoTest.kt create mode 100644 src/test/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubTimelineDtoTest.kt create mode 100644 src/test/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueThreadTest.kt diff --git a/src/test/kotlin/com/github/vhrabar/issuehub/model/IssueFilterOptionsTest.kt b/src/test/kotlin/com/github/vhrabar/issuehub/model/IssueFilterOptionsTest.kt new file mode 100644 index 0000000..915f1fd --- /dev/null +++ b/src/test/kotlin/com/github/vhrabar/issuehub/model/IssueFilterOptionsTest.kt @@ -0,0 +1,73 @@ +package com.github.vhrabar.issuehub.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class IssueFilterOptionsTest { + @Test + fun `only the default open view counts as unfiltered`() { + assertFalse(IssueQuery().isFiltered) + assertFalse(IssueQuery(sortField = IssueSortField.UPDATED).isFiltered) + assertTrue(IssueQuery(text = "crash").isFiltered) + assertTrue(IssueQuery(state = IssueStateFilter.ALL).isFiltered) + assertTrue(IssueQuery(labels = setOf("bug")).isFiltered) + assertTrue(IssueQuery(assignee = AssigneeFilter.Unassigned).isFiltered) + assertTrue(IssueQuery(milestone = MilestoneFilter.None).isFiltered) + } + + @Test + fun `merging de-duplicates and orders case-insensitively`() { + val provider = + IssueFilterOptions( + labels = listOf(IssueLabel("bug", "d73a4a")), + assignees = listOf("Zoe", "adam"), + milestones = listOf(IssueMilestone(1, "v1.0")), + authors = listOf("adam"), + ) + val discovered = + IssueFilterOptions( + labels = listOf(IssueLabel("bug", null), IssueLabel("Api", null)), + assignees = listOf("adam", "mia"), + milestones = listOf(IssueMilestone(1, "v1.0"), IssueMilestone(2, "Backlog")), + authors = listOf("Zoe"), + ) + + val merged = provider.mergedWith(discovered) + + assertEquals(listOf("Api", "bug"), merged.labels.map { it.name }) + assertEquals("d73a4a", merged.labels.first { it.name == "bug" }.color) + assertEquals(listOf("adam", "mia", "Zoe"), merged.assignees) + assertEquals(listOf("Backlog", "v1.0"), merged.milestones.map { it.title }) + assertEquals(listOf("adam", "Zoe"), merged.authors) + } + + @Test + fun `loaded issues contribute the values they mention`() { + val options = optionsFrom(listOf(issue(1, assignee = "mia", author = "adam"), issue(2, author = "mia"))) + + assertEquals(listOf("bug"), options.labels.map { it.name }) + assertEquals(listOf("mia"), options.assignees) + assertEquals(listOf("v1.0"), options.milestones.map { it.title }) + assertEquals(listOf("adam", "mia"), options.authors) + } + + private fun issue( + number: Int, + assignee: String? = null, + author: String? = null, + ) = Issue( + id = number, + displayNumber = "#$number", + title = "Issue $number", + state = IssueState.OPEN, + labels = listOf(IssueLabel("bug", "d73a4a")), + assignee = assignee?.let { IssueActor(it) }, + milestone = IssueMilestone(1, "v1.0"), + author = author?.let { IssueActor(it) }, + url = "https://github.test/octocat/hello-world/issues/$number", + createdAt = "2026-07-01T00:00:00Z", + updatedAt = "2026-07-02T00:00:00Z", + ) +} diff --git a/src/test/kotlin/com/github/vhrabar/issuehub/provider/github/CommitWebUrlTest.kt b/src/test/kotlin/com/github/vhrabar/issuehub/provider/github/CommitWebUrlTest.kt new file mode 100644 index 0000000..32d0d60 --- /dev/null +++ b/src/test/kotlin/com/github/vhrabar/issuehub/provider/github/CommitWebUrlTest.kt @@ -0,0 +1,40 @@ +package com.github.vhrabar.issuehub.provider.github + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CommitWebUrlTest { + @Test + fun `the api host and plural path become the browsable commit page`() { + assertEquals( + "https://github.com/octocat/hello-world/commit/0123456789abcdef", + commitWebUrl("https://api.github.com/repos/octocat/hello-world/commits/0123456789abcdef"), + ) + } + + /** A commit referencing an issue often lives in a fork, so the repo comes from the URL itself. */ + @Test + fun `the owner and name are taken from the address, not assumed`() { + assertEquals( + "https://github.com/contributor/hello-world/commit/abc123", + commitWebUrl("https://api.github.com/repos/contributor/hello-world/commits/abc123"), + ) + } + + /** Enterprise installs serve the API from a path rather than an `api.` host. */ + @Test + fun `an enterprise api path is stripped`() { + assertEquals( + "https://ghe.example.com/octocat/hello-world/commit/abc123", + commitWebUrl("https://ghe.example.com/api/v3/repos/octocat/hello-world/commits/abc123"), + ) + } + + @Test + fun `an address we don't recognise yields no link at all`() { + assertNull(commitWebUrl("https://api.github.com/octocat/hello-world/commits/abc123")) + assertNull(commitWebUrl("https://api.github.com/repos/octocat/hello-world/pulls/21")) + assertNull(commitWebUrl("")) + } +} diff --git a/src/test/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubClientUriTest.kt b/src/test/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubClientUriTest.kt new file mode 100644 index 0000000..98f4b8d --- /dev/null +++ b/src/test/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubClientUriTest.kt @@ -0,0 +1,108 @@ +package com.github.vhrabar.issuehub.provider.github + +import com.github.vhrabar.issuehub.model.AssigneeFilter +import com.github.vhrabar.issuehub.model.IssueMilestone +import com.github.vhrabar.issuehub.model.IssueQuery +import com.github.vhrabar.issuehub.model.IssueSortDirection +import com.github.vhrabar.issuehub.model.IssueSortField +import com.github.vhrabar.issuehub.model.IssueStateFilter +import com.github.vhrabar.issuehub.model.MilestoneFilter +import org.junit.Assert.assertEquals +import org.junit.Test +import java.net.URI +import java.net.URLDecoder +import java.nio.charset.StandardCharsets + +class GitHubClientUriTest { + private val client = GitHubClient(BASE_URL) + private val repo = RepoCoordinates("octocat", "hello-world") + + @Test + fun `default query asks for open issues, newest first`() { + assertEquals( + "$BASE_URL/repos/octocat/hello-world/issues?state=open&sort=created&direction=desc&per_page=50", + client.listIssuesUri(repo, IssueQuery()).toString(), + ) + } + + @Test + fun `filters map onto issue list parameters`() { + val uri = client.listIssuesUri(repo, filtered()) + + assertEquals( + "$BASE_URL/repos/octocat/hello-world/issues" + + "?state=all&sort=comments&direction=asc&per_page=50" + + "&labels=bug%2Chelp+wanted&creator=octocat&assignee=none&milestone=3", + uri.toString(), + ) + } + + @Test + fun `free text switches to the search endpoint`() { + val uri = client.searchIssuesUri(repo, filtered().copy(text = " crash on save ")) + + assertEquals("/search/issues", uri.path) + assertEquals( + "repo:octocat/hello-world is:issue " + + """label:"bug" label:"help wanted" author:octocat no:assignee milestone:"v1.0" """ + + "crash on save", + uri.decodedParam("q"), + ) + // The search endpoint spells the direction differently from the list endpoint. + assertEquals("comments", uri.decodedParam("sort")) + assertEquals("asc", uri.decodedParam("order")) + } + + @Test + fun `search keeps the state term unless every state is wanted`() { + assertEquals( + "repo:octocat/hello-world is:issue is:open needle", + client.searchIssuesUri(repo, IssueQuery(text = "needle")).decodedParam("q"), + ) + assertEquals( + "repo:octocat/hello-world is:issue is:closed needle", + client + .searchIssuesUri(repo, IssueQuery(text = "needle", state = IssueStateFilter.CLOSED)) + .decodedParam("q"), + ) + } + + @Test + fun `issue detail addresses a single issue by number`() { + assertEquals( + "$BASE_URL/repos/octocat/hello-world/issues/17", + client.issueUri(repo, 17).toString(), + ) + } + + @Test + fun `the timeline is paged off the issue itself`() { + assertEquals( + "$BASE_URL/repos/octocat/hello-world/issues/17/timeline?per_page=100&page=2", + client.timelineUri(repo, 17, 2).toString(), + ) + } + + private fun filtered() = + IssueQuery( + state = IssueStateFilter.ALL, + labels = setOf("bug", "help wanted"), + assignee = AssigneeFilter.Unassigned, + author = "octocat", + milestone = MilestoneFilter.Named(IssueMilestone(3, "v1.0")), + sortField = IssueSortField.COMMENTS, + sortDirection = IssueSortDirection.ASC, + ) + + /** [URI.getQuery] decodes `+` as a literal plus, so split the raw query and decode by hand. */ + private fun URI.decodedParam(name: String): String = + rawQuery + .split("&") + .first { it.startsWith("$name=") } + .substringAfter("=") + .let { URLDecoder.decode(it, StandardCharsets.UTF_8) } + + private companion object { + const val BASE_URL = "https://api.github.test" + } +} diff --git a/src/test/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubIssueDtoTest.kt b/src/test/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubIssueDtoTest.kt new file mode 100644 index 0000000..28421f4 --- /dev/null +++ b/src/test/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubIssueDtoTest.kt @@ -0,0 +1,45 @@ +package com.github.vhrabar.issuehub.provider.github + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class GitHubIssueDtoTest { + private val json = + Json { + ignoreUnknownKeys = true + coerceInputValues = true + } + + @Test + fun `the full media type carries the rendered body alongside the source`() { + val dto = json.decodeFromString(issueJson(""""body_html": "

Renders this.

",""")) + + assertEquals("Renders *this*.", dto.body) + assertEquals("

Renders this.

", dto.bodyHtml) + } + + /** List responses use the default media type, so rows decode with no rendered body at all. */ + @Test + fun `a response without the rendered body still decodes`() { + val dto = json.decodeFromString(issueJson("")) + + assertEquals("Renders *this*.", dto.body) + assertNull(dto.bodyHtml) + } + + private fun issueJson(bodyHtml: String) = + """ + { + "number": 17, + "title": "Filter issues", + "state": "open", + "body": "Renders *this*.", + $bodyHtml + "html_url": "https://github.test/octocat/hello-world/issues/17", + "created_at": "2026-07-01T00:00:00Z", + "updated_at": "2026-07-02T00:00:00Z" + } + """.trimIndent() +} diff --git a/src/test/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubTimelineDtoTest.kt b/src/test/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubTimelineDtoTest.kt new file mode 100644 index 0000000..dcbb865 --- /dev/null +++ b/src/test/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubTimelineDtoTest.kt @@ -0,0 +1,259 @@ +package com.github.vhrabar.issuehub.provider.github + +import com.github.vhrabar.issuehub.model.IssueMilestone +import com.github.vhrabar.issuehub.model.IssueState +import com.github.vhrabar.issuehub.model.IssueTimelineItem +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * GitHub squeezes every kind of history entry through one JSON shape, so the decoding and the + * mapping back onto [IssueTimelineItem] are where a whole class of entries can silently vanish. + */ +class GitHubTimelineDtoTest { + private val json = + Json { + ignoreUnknownKeys = true + coerceInputValues = true + } + + @Test + fun `a comment keeps its rendered body and names its writer`() { + val item = + item( + """ + { + "event": "commented", + "user": {"login": "octocat", "avatar_url": "https://avatars.test/octocat"}, + "created_at": "2026-07-03T10:00:00Z", + "updated_at": "2026-07-03T10:00:00Z", + "body": "Still *broken*.", + "body_html": "

Still broken.

", + "html_url": "https://github.test/octocat/hello-world/issues/17#issuecomment-1" + } + """, + ) as IssueTimelineItem.Comment + + assertEquals("octocat", item.actor?.login) + assertEquals("2026-07-03T10:00:00Z", item.at) + assertEquals("Still *broken*.", item.body) + assertEquals("

Still broken.

", item.bodyHtml) + // An unedited comment is stamped as updated the moment it is written. + assertFalse(item.edited) + } + + @Test + fun `a later update marks a comment as edited`() { + val item = + item( + """ + { + "event": "commented", + "user": {"login": "octocat"}, + "created_at": "2026-07-03T10:00:00Z", + "updated_at": "2026-07-03T11:00:00Z", + "body": "Fixed the typo." + } + """, + ) as IssueTimelineItem.Comment + + assertTrue(item.edited) + } + + @Test + fun `closing carries the reason and the actor who did it`() { + val item = + item( + """ + { + "event": "closed", + "actor": {"login": "maintainer"}, + "created_at": "2026-07-04T09:00:00Z", + "state_reason": "not_planned" + } + """, + ) as IssueTimelineItem.StateChange + + assertEquals(IssueState.CLOSED, item.state) + assertEquals("not_planned", item.reason) + assertEquals("maintainer", item.actor?.login) + } + + @Test + fun `reopening comes back as a state change without a reason`() { + val item = + item("""{"event": "reopened", "actor": {"login": "octocat"}, "created_at": "2026-07-05T09:00:00Z"}""") + as IssueTimelineItem.StateChange + + assertEquals(IssueState.OPEN, item.state) + assertNull(item.reason) + } + + @Test + fun `label entries distinguish adding from removing`() { + val added = + item( + """{"event": "labeled", "actor": {"login": "bot"}, "created_at": "2026-07-04T09:00:00Z", "label": {"name": "bug", "color": "d73a4a"}}""", + ) as IssueTimelineItem.LabelChange + val removed = + item( + """{"event": "unlabeled", "actor": {"login": "bot"}, "created_at": "2026-07-04T09:01:00Z", "label": {"name": "bug"}}""", + ) as IssueTimelineItem.LabelChange + + assertTrue(added.added) + assertEquals("bug", added.label.name) + assertEquals("d73a4a", added.label.color) + assertFalse(removed.added) + } + + @Test + fun `assignee entries name the assignee, not just the actor`() { + val item = + item( + """ + { + "event": "assigned", + "actor": {"login": "maintainer"}, + "assignee": {"login": "octocat"}, + "created_at": "2026-07-04T09:00:00Z" + } + """, + ) as IssueTimelineItem.AssigneeChange + + assertEquals("maintainer", item.actor?.login) + assertEquals("octocat", item.assignee.login) + assertTrue(item.added) + } + + /** The timeline names a milestone by title only, so there is no number to map. */ + @Test + fun `milestone entries fall back to the unknown number`() { + val item = + item( + """{"event": "milestoned", "actor": {"login": "octocat"}, "created_at": "2026-07-04T09:00:00Z", "milestone": {"title": "v1.0"}}""", + ) as IssueTimelineItem.MilestoneChange + + assertEquals("v1.0", item.milestone.title) + assertEquals(IssueMilestone.NUMBER_UNKNOWN, item.milestone.number) + } + + @Test + fun `a rename keeps both titles`() { + val item = + item( + """ + { + "event": "renamed", + "actor": {"login": "octocat"}, + "created_at": "2026-07-04T09:00:00Z", + "rename": {"from": "Broke", "to": "Crash on save"} + } + """, + ) as IssueTimelineItem.Renamed + + assertEquals("Broke", item.from) + assertEquals("Crash on save", item.to) + } + + @Test + fun `a cross reference unwraps the issue it points at`() { + val item = + item( + """ + { + "event": "cross-referenced", + "actor": {"login": "octocat"}, + "created_at": "2026-07-04T09:00:00Z", + "source": { + "type": "issue", + "issue": { + "number": 21, + "title": "Follow-up fix", + "state": "open", + "html_url": "https://github.test/octocat/hello-world/pull/21", + "created_at": "2026-07-04T08:00:00Z", + "updated_at": "2026-07-04T08:30:00Z", + "pull_request": {"url": "https://api.github.test/repos/octocat/hello-world/pulls/21"} + } + } + } + """, + ) as IssueTimelineItem.CrossReferenced + + assertEquals("#21", item.displayNumber) + assertEquals("Follow-up fix", item.title) + assertEquals("https://github.test/octocat/hello-world/pull/21", item.url) + assertTrue(item.isPullRequest) + } + + /** GitHub hands back the API address, which renders JSON; the link has to reach the web page. */ + @Test + fun `a commit reference links to the commit page, not the API`() { + val item = + item( + """ + { + "event": "referenced", + "actor": {"login": "octocat"}, + "created_at": "2026-07-04T09:00:00Z", + "commit_id": "0123456789abcdef", + "commit_url": "https://api.github.com/repos/octocat/hello-world/commits/0123456789abcdef" + } + """, + ) as IssueTimelineItem.Referenced + + assertEquals("0123456789abcdef", item.commitSha) + assertEquals("https://github.com/octocat/hello-world/commit/0123456789abcdef", item.commitUrl) + } + + @Test + fun `a commit reference without a link still names the commit`() { + val item = + item("""{"event": "referenced", "created_at": "2026-07-04T09:00:00Z", "commit_id": "0123456789abcdef"}""") + as IssueTimelineItem.Referenced + + assertEquals("0123456789abcdef", item.commitSha) + assertNull(item.commitUrl) + } + + /** An entry we have no case for still shows up, rather than being dropped on the floor. */ + @Test + fun `an unmodelled entry keeps the provider's own name for it`() { + val item = + item("""{"event": "pinned", "actor": {"login": "octocat"}, "created_at": "2026-07-04T09:00:00Z"}""") + as IssueTimelineItem.Unknown + + assertEquals("pinned", item.kind) + } + + @Test + fun `entries without a payload or a date are dropped`() { + // `committed` entries are dated by the commit author instead of GitHub. + assertNull(item("""{"event": "committed", "message": "Fix it"}""")) + assertNull(item("""{"event": "labeled", "created_at": "2026-07-04T09:00:00Z"}""")) + assertNull(item("""{"event": "renamed", "created_at": "2026-07-04T09:00:00Z"}""")) + } + + @Test + fun `a whole page decodes even when entries disagree about their fields`() { + val page = + json.decodeFromString>( + """ + [ + {"event": "labeled", "created_at": "2026-07-04T09:00:00Z", "label": {"name": "bug"}}, + {"event": "commented", "created_at": "2026-07-04T10:00:00Z", "user": {"login": "octocat"}, "body": "Hi"}, + {"event": "closed", "created_at": "2026-07-04T11:00:00Z", "actor": {"login": "maintainer"}} + ] + """.trimIndent(), + ) + + assertEquals(3, page.mapNotNull { it.toTimelineItem() }.size) + } + + private fun item(payload: String): IssueTimelineItem? = + json.decodeFromString(payload.trimIndent()).toTimelineItem() +} diff --git a/src/test/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueThreadTest.kt b/src/test/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueThreadTest.kt new file mode 100644 index 0000000..c821745 --- /dev/null +++ b/src/test/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueThreadTest.kt @@ -0,0 +1,108 @@ +package com.github.vhrabar.issuehub.toolWindow + +import com.github.vhrabar.issuehub.model.Issue +import com.github.vhrabar.issuehub.model.IssueActor +import com.github.vhrabar.issuehub.model.IssueDetail +import com.github.vhrabar.issuehub.model.IssueLabel +import com.github.vhrabar.issuehub.model.IssueState +import com.github.vhrabar.issuehub.model.IssueTimelineItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class IssueThreadTest { + @Test + fun `the description opens the thread as its author's comment`() { + val cards = issueThread(issue(), IssueDetail(issue(), bodyHtml = "

Renders this.

")) + + assertEquals(1, cards.size) + val opening = cards.single() + assertEquals("octocat", opening.actor?.login) + assertTrue(opening.opensTheIssue) + val comment = opening.items.single() as IssueTimelineItem.Comment + assertEquals("

Renders this.

", comment.bodyHtml) + assertEquals("2026-07-01T00:00:00Z", comment.at) + } + + @Test + fun `an issue nobody touched is still one card`() { + val cards = issueThread(issue(body = null), null) + + assertEquals(1, cards.size) + assertNull((cards.single().items.single() as IssueTimelineItem.Comment).body) + } + + @Test + fun `a run of entries by one account collapses into a single card`() { + val cards = + issueThread( + issue(), + detail( + label("bot", "2026-07-02T09:00:00Z", "bug"), + label("bot", "2026-07-02T09:01:00Z", "triage"), + label("bot", "2026-07-02T09:02:00Z", "help wanted"), + ), + ) + + // The description's card, then one card covering all three labels. + assertEquals(2, cards.size) + assertEquals("bot", cards[1].actor?.login) + assertEquals(3, cards[1].items.size) + assertFalse(cards[1].opensTheIssue) + } + + @Test + fun `entries by the description's author join its card`() { + val cards = issueThread(issue(), detail(label("octocat", "2026-07-02T09:00:00Z", "bug"))) + + assertEquals(1, cards.size) + assertEquals(2, cards.single().items.size) + assertTrue(cards.single().opensTheIssue) + } + + /** Grouping must not reorder: a card covers one continuous stretch, not everything an account did. */ + @Test + fun `an account coming back after someone else gets a second card`() { + val cards = + issueThread( + issue(), + detail( + label("bot", "2026-07-02T09:00:00Z", "bug"), + label("maintainer", "2026-07-02T10:00:00Z", "triage"), + label("bot", "2026-07-02T11:00:00Z", "wontfix"), + ), + ) + + assertEquals(listOf("octocat", "bot", "maintainer", "bot"), cards.map { it.actor?.login }) + } + + @Test + fun `only the first card opens the issue`() { + val cards = issueThread(issue(), detail(label("bot", "2026-07-02T09:00:00Z", "bug"))) + + assertEquals(listOf(true, false), cards.map { it.opensTheIssue }) + } + + private fun issue(body: String? = "Renders *this*.") = + Issue( + id = 17, + displayNumber = "#17", + title = "Filter issues", + state = IssueState.OPEN, + body = body, + author = IssueActor("octocat"), + url = "https://github.test/octocat/hello-world/issues/17", + createdAt = "2026-07-01T00:00:00Z", + updatedAt = "2026-07-02T00:00:00Z", + ) + + private fun detail(vararg timeline: IssueTimelineItem) = IssueDetail(issue(), timeline = timeline.toList()) + + private fun label( + who: String, + at: String, + name: String, + ) = IssueTimelineItem.LabelChange(IssueActor(who), at, IssueLabel(name), added = true) +} From 8ebe641805cec60ed9ea10195ad0f1b0dd702ef9 Mon Sep 17 00:00:00 2001 From: Vedran Hrabar Date: Tue, 4 Aug 2026 12:53:21 +0200 Subject: [PATCH 16/20] feat(Qodana): fix Qodana-reported issues Signed-off-by: Vedran Hrabar --- .../com/github/vhrabar/issuehub/toolWindow/IssueFilterBar.kt | 1 + .../vhrabar/issuehub/toolWindow/IssueHubToolWindowFactory.kt | 1 + .../com/github/vhrabar/issuehub/toolWindow/IssueThreadPanel.kt | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueFilterBar.kt b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueFilterBar.kt index 59ebe87..c36732f 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueFilterBar.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueFilterBar.kt @@ -40,6 +40,7 @@ import javax.swing.event.DocumentEvent * Owns the current [query] and reports every change through [onQueryChanged]; it never touches * the list itself. */ +@Suppress("UnstableApiUsage") internal class IssueFilterBar( parent: Disposable, trailing: JComponent, diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueHubToolWindowFactory.kt b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueHubToolWindowFactory.kt index afac0d5..9d8c9da 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueHubToolWindowFactory.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueHubToolWindowFactory.kt @@ -39,6 +39,7 @@ import javax.swing.SwingUtilities import javax.swing.ToolTipManager class IssueHubToolWindowFactory : ToolWindowFactory { + @Suppress("UnstableApiUsage", "UsePropertyAccessSyntax") // setDisposer has no property form: getDisposer is nullable, setDisposer is not, override fun createToolWindowContent( project: Project, toolWindow: ToolWindow, diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueThreadPanel.kt b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueThreadPanel.kt index 4dd1a6c..1375005 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueThreadPanel.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueThreadPanel.kt @@ -34,6 +34,7 @@ import javax.swing.event.HyperlinkEvent * icons, not images an HTML pane could fetch; each card still renders its own text through the * platform's HTML pane, which themes GitHub's markup to match the IDE. */ +@Suppress("UnstableApiUsage") internal class IssueThreadPanel : JBPanel(VerticalLayout(JBUI.scale(CARD_GAP))), Scrollable, @@ -198,7 +199,7 @@ internal class IssueThreadPanel : const val CARD_GAP = 8 const val CARD_PADDING = 10 const val CARD_ARC = 10 - val AVATAR_SIZE = CircularAvatarIcon.MAX_SIZE + const val AVATAR_SIZE = CircularAvatarIcon.MAX_SIZE const val AUTHOR_WIDTH = 150 const val SCROLL_UNIT = 16 } From 26ba88e80295ea515ca8e74879f2cae04c5383fb Mon Sep 17 00:00:00 2001 From: Vedran Hrabar Date: Tue, 4 Aug 2026 12:53:51 +0200 Subject: [PATCH 17/20] docs: update CHANGELOG and README to reflect new features Signed-off-by: Vedran Hrabar --- CHANGELOG.md | 12 +++++++++++- README.md | 12 +++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39c530b..854b9ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,16 @@ - Label tooltips with colour swatches matching the label colours configured on GitHub. - Dedicated IssueHub tool window icons for the light and dark themes. +- Search field above the issue list that matches issue titles and bodies as you type. +- Filter dropdowns for state, author, assignee (including *Unassigned*), labels and milestone (including *No milestone*), plus a **Reset** action. +- Sorting by creation date, last update or comment count, ascending or descending. +- Issue detail view opened in the editor area as its own tab, with the title, metadata and the rendered description, plus **Refresh** and **Open on GitHub** actions. Being an editor, it takes the full window width and can be split next to your code. +- The issue tab now shows the full history below the description: comments (rendered, and marked when edited) alongside closes, reopens, label, assignee and milestone changes, title edits, and references from other issues and commits. +- The history reads as a thread of cards, one per run of activity by the same account, with that account's avatar and name down the left and what they did to the right of it. Every entry carries an icon for its kind, and label changes show the label's own colour. + ### Changed +- Double-clicking an issue now opens it in the editor instead of the browser; **Open on GitHub** in the issue tab still takes you to GitHub. - The tool window header shows the **IssueHub** title instead of the generic tool window ID label. - Rows ellipsize to the panel width, so the horizontal scrollbar is gone. - Pull requests are filtered out of the issue list; only real issues are shown. @@ -21,7 +29,9 @@ - Token entry is still a temporary placeholder; there is no dedicated settings/configuration UI yet. - GitHub is the only supported provider. -- The list shows the 50 most recent open issues; no search or filtering. +- The list shows at most 50 issues per query; there is no pagination. +- An issue's history is read up to 500 entries; anything past that is not shown. +- The author and assignee dropdowns offer repository collaborators plus anyone seen on the loaded issues; the full list needs a token with push access. ### Compatibility diff --git a/README.md b/README.md index 070f650..9f2e7d3 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,9 @@ -> **Alpha.** Core flow works (detect repo > add token > list issues). Configuration is a -> placeholder, GitHub is the only supported provider. +> **Alpha.** Core flow works (detect repo > add token > browse, filter and read issues). +> Configuration is a placeholder, GitHub is the only supported provider, and IssueHub reads issues +> only, it never writes. **IssueHub** brings your GitHub issues into the IDE. Browse and open issues for the current @@ -24,6 +25,8 @@ repository from a dedicated tool window, without leaving your editor. - Sort by creation date, last update or comment count - Opens an issue as an editor tab, full width and splittable next to your code, with the description rendered in the IDE's own styling +- Reads the whole thread in that tab: comments alongside closes, reopens, label, assignee and + milestone changes, title edits, and references from other issues and commits - Jumps to the issue on GitHub whenever you need the browser - Stores your GitHub token in the IDE's secure credential store @@ -38,7 +41,10 @@ repository from a dedicated tool window, without leaving your editor. credential store, never in plain text). 4. Click **Refresh** to load issues. Double-click an issue to open it as an editor tab, titled with the issue number; **Open on GitHub** there opens the same issue in your browser. -5. Use the search field and the **State / Author / Assignee / Label / Milestone / Sort** dropdowns to +5. The issue tab shows the description and, below it, the issue's history as a thread of cards: + comments plus the closes, reopens, label, assignee and milestone changes and title edits around + them. **Refresh** in that tab re-reads the issue from GitHub. +6. Use the search field and the **State / Author / Assignee / Label / Milestone / Sort** dropdowns to narrow the list; **Reset** clears everything back to open issues, newest first. Filtering and sorting run on GitHub's side, so the results are the whole repository's issues, not From 916ad6f9b1f178a1c67651d16e438229da9d4d95 Mon Sep 17 00:00:00 2001 From: Vedran Hrabar Date: Tue, 4 Aug 2026 13:05:25 +0200 Subject: [PATCH 18/20] chore(Qodana): update linter version to 2026.2 & fix found issues Signed-off-by: Vedran Hrabar --- qodana.yml | 6 +----- .../com/github/vhrabar/issuehub/editor/IssueFileEditor.kt | 2 +- .../github/vhrabar/issuehub/toolWindow/IssueDetailPanel.kt | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/qodana.yml b/qodana.yml index 982dacd..1958ee6 100644 --- a/qodana.yml +++ b/qodana.yml @@ -1,13 +1,9 @@ # Qodana configuration: https://www.jetbrains.com/help/qodana/qodana-yaml.html -# Runs the same inspections as the IDE (e.g. UseJBColor) headlessly in CI. version: "1.0" -# JVM community linter is enough for a Kotlin plugin; bump the tag to match your IDE line. -linter: jetbrains/qodana-jvm-community:2026.1 +linter: jetbrains/qodana-jvm-community:2026.2 -# Recommended profile: a balanced set of inspections without the noisiest ones. profile: name: qodana.recommended -# Fail the CI run when new problems are introduced above these thresholds. failThreshold: 0 diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/editor/IssueFileEditor.kt b/src/main/kotlin/com/github/vhrabar/issuehub/editor/IssueFileEditor.kt index 7e4710c..41437e3 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/editor/IssueFileEditor.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/editor/IssueFileEditor.kt @@ -22,7 +22,7 @@ internal class IssueFileEditor( override fun getComponent(): JComponent = panel - override fun getPreferredFocusedComponent(): JComponent = panel.preferredFocusComponent + override fun getPreferredFocusedComponent(): JComponent = panel.preferredFocusComponent() override fun getName(): String = file.issue.displayNumber diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueDetailPanel.kt b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueDetailPanel.kt index 3663a16..d6008e4 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueDetailPanel.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueDetailPanel.kt @@ -77,7 +77,7 @@ internal class IssueDetailPanel( private var requestId = 0 /** The thread, so an editor host can hand it the focus and arrow keys scroll straight away. */ - internal val preferredFocusComponent: JComponent get() = thread + internal fun preferredFocusComponent(): JComponent = thread init { add(header(), BorderLayout.NORTH) From 0da8cd82a6777d5802f95bce79cc70180bd4396b Mon Sep 17 00:00:00 2001 From: Vedran Hrabar Date: Tue, 4 Aug 2026 15:48:44 +0200 Subject: [PATCH 19/20] chore: update IntelliJ version to 2025.3 and adjust compatibility in CHANGELOG Signed-off-by: Vedran Hrabar --- CHANGELOG.md | 2 +- build.gradle.kts | 2 +- gradle.properties | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 854b9ff..f682b81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,7 @@ ### Compatibility -- Verified against IntelliJ Platform 2025.2 through 2026.2 +- Verified against IntelliJ Platform 2025.3 through 2026.3rc ## [0.0.2] - 2026-07-21 ### Added diff --git a/build.gradle.kts b/build.gradle.kts index 0b6293e..85be2af 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,7 +15,7 @@ dependencies { // IntelliJ Platform Gradle Plugin Dependencies Extension - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-dependencies-extension.html intellijPlatform { - intellijIdea("2025.2.6.2") + intellijIdea("2025.3") testFramework(TestFrameworkType.Platform) } } diff --git a/gradle.properties b/gradle.properties index 6172051..8dcfab1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ group = com.github.vhrabar.issuehub -version = 0.0.3 +version = 0.0.4 pluginRepositoryUrl = https://github.com/vhrabar/IssueHub From bd0e582e87901f46743b8fa46effa762bd53e8b2 Mon Sep 17 00:00:00 2001 From: Vedran Hrabar Date: Tue, 4 Aug 2026 16:05:40 +0200 Subject: [PATCH 20/20] refactor(spotless): run spotles-apply Signed-off-by: Vedran Hrabar --- .../provider/github/GitHubIssueProvider.kt | 41 +++++++++++----- .../issuehub/toolWindow/IssueCellRenderer.kt | 18 +++++-- .../toolWindow/IssueHubToolWindowFactory.kt | 3 +- .../vhrabar/issuehub/toolWindow/IssueIcons.kt | 48 +++++++++++++++---- .../issuehub/toolWindow/IssueThreadHtml.kt | 28 +++++++---- 5 files changed, 103 insertions(+), 35 deletions(-) diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubIssueProvider.kt b/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubIssueProvider.kt index 44f1341..668c9ee 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubIssueProvider.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/provider/github/GitHubIssueProvider.kt @@ -44,7 +44,7 @@ internal fun GitHubTimelineEventDto.toTimelineItem(): IssueTimelineItem? { val at = createdAt ?: return null val who = (actor ?: user)?.toActor() return when (event) { - "commented" -> + "commented" -> { IssueTimelineItem.Comment( actor = who, at = at, @@ -53,17 +53,25 @@ internal fun GitHubTimelineEventDto.toTimelineItem(): IssueTimelineItem? { url = htmlUrl, edited = updatedAt != null && updatedAt != createdAt, ) + } - "closed" -> IssueTimelineItem.StateChange(who, at, IssueState.CLOSED, stateReason) - "reopened" -> IssueTimelineItem.StateChange(who, at, IssueState.OPEN) + "closed" -> { + IssueTimelineItem.StateChange(who, at, IssueState.CLOSED, stateReason) + } - "labeled", "unlabeled" -> + "reopened" -> { + IssueTimelineItem.StateChange(who, at, IssueState.OPEN) + } + + "labeled", "unlabeled" -> { label?.let { IssueTimelineItem.LabelChange(who, at, IssueLabel(it.name, it.color), added = event == "labeled") } + } - "assigned", "unassigned" -> + "assigned", "unassigned" -> { assignee?.let { IssueTimelineItem.AssigneeChange(who, at, it.toActor(), added = event == "assigned") } + } - "milestoned", "demilestoned" -> + "milestoned", "demilestoned" -> { milestone?.let { IssueTimelineItem.MilestoneChange( actor = who, @@ -72,10 +80,13 @@ internal fun GitHubTimelineEventDto.toTimelineItem(): IssueTimelineItem? { added = event == "milestoned", ) } + } - "renamed" -> rename?.let { IssueTimelineItem.Renamed(who, at, it.from, it.to) } + "renamed" -> { + rename?.let { IssueTimelineItem.Renamed(who, at, it.from, it.to) } + } - "cross-referenced" -> + "cross-referenced" -> { source?.issue?.let { IssueTimelineItem.CrossReferenced( actor = who, @@ -86,11 +97,19 @@ internal fun GitHubTimelineEventDto.toTimelineItem(): IssueTimelineItem? { isPullRequest = it.isPullRequest, ) } + } + + "referenced" -> { + commitId?.let { IssueTimelineItem.Referenced(who, at, it, commitUrl?.let(::commitWebUrl)) } + } - "referenced" -> commitId?.let { IssueTimelineItem.Referenced(who, at, it, commitUrl?.let(::commitWebUrl)) } + null -> { + null + } - null -> null - else -> IssueTimelineItem.Unknown(who, at, event) + else -> { + IssueTimelineItem.Unknown(who, at, event) + } } } diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueCellRenderer.kt b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueCellRenderer.kt index 1878bf8..5adb7b9 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueCellRenderer.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueCellRenderer.kt @@ -181,11 +181,21 @@ internal class IssueCellRenderer( val created = formatDate(value.createdAt) val author = value.author?.login return when { - author != null && created != null -> + author != null && created != null -> { IssueHubBundle["issue.meta.createdBy", value.displayNumber, created, author] - created != null -> IssueHubBundle["issue.meta.created", value.displayNumber, created] - author != null -> IssueHubBundle["issue.meta.by", value.displayNumber, author] - else -> value.displayNumber + } + + created != null -> { + IssueHubBundle["issue.meta.created", value.displayNumber, created] + } + + author != null -> { + IssueHubBundle["issue.meta.by", value.displayNumber, author] + } + + else -> { + value.displayNumber + } } } diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueHubToolWindowFactory.kt b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueHubToolWindowFactory.kt index 9d8c9da..348979a 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueHubToolWindowFactory.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueHubToolWindowFactory.kt @@ -39,7 +39,8 @@ import javax.swing.SwingUtilities import javax.swing.ToolTipManager class IssueHubToolWindowFactory : ToolWindowFactory { - @Suppress("UnstableApiUsage", "UsePropertyAccessSyntax") // setDisposer has no property form: getDisposer is nullable, setDisposer is not, + // setDisposer has no property form: getDisposer is nullable, setDisposer is not + @Suppress("UnstableApiUsage", "UsePropertyAccessSyntax") override fun createToolWindowContent( project: Project, toolWindow: ToolWindow, diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueIcons.kt b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueIcons.kt index 006a972..372d6d4 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueIcons.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueIcons.kt @@ -71,19 +71,47 @@ internal object ThreadIcons { /** Null for a name we don't know, which the pane renders as nothing at all. */ fun resolve(key: String): Icon? = when (key) { - COMMENT -> IssueEventIcon(IssueEventIcon.Kind.COMMENT) - OPENED, REOPENED -> IssueStateIcon(IssueState.OPEN) - CLOSED -> IssueStateIcon(IssueState.CLOSED) - ASSIGNEE -> IssueEventIcon(IssueEventIcon.Kind.ASSIGNEE) - MILESTONE -> IssueEventIcon(IssueEventIcon.Kind.MILESTONE) - RENAME -> IssueEventIcon(IssueEventIcon.Kind.RENAME) - REFERENCE -> IssueEventIcon(IssueEventIcon.Kind.REFERENCE) - COMMIT -> IssueEventIcon(IssueEventIcon.Kind.COMMIT) - OTHER -> IssueEventIcon(IssueEventIcon.Kind.OTHER) - else -> + COMMENT -> { + IssueEventIcon(IssueEventIcon.Kind.COMMENT) + } + + OPENED, REOPENED -> { + IssueStateIcon(IssueState.OPEN) + } + + CLOSED -> { + IssueStateIcon(IssueState.CLOSED) + } + + ASSIGNEE -> { + IssueEventIcon(IssueEventIcon.Kind.ASSIGNEE) + } + + MILESTONE -> { + IssueEventIcon(IssueEventIcon.Kind.MILESTONE) + } + + RENAME -> { + IssueEventIcon(IssueEventIcon.Kind.RENAME) + } + + REFERENCE -> { + IssueEventIcon(IssueEventIcon.Kind.REFERENCE) + } + + COMMIT -> { + IssueEventIcon(IssueEventIcon.Kind.COMMIT) + } + + OTHER -> { + IssueEventIcon(IssueEventIcon.Kind.OTHER) + } + + else -> { key .takeIf { it.startsWith(LABEL_PREFIX) } ?.let { IssueLabelIcon(labelTint(it.removePrefix(LABEL_PREFIX).ifEmpty { null }, UIUtil.getPanelBackground())) } + } } } diff --git a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueThreadHtml.kt b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueThreadHtml.kt index 6928ba2..6d51ee9 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueThreadHtml.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueThreadHtml.kt @@ -28,9 +28,11 @@ private fun IssueTimelineItem.toHtml( ): String { val on = formatAt(at) return when (this) { - is IssueTimelineItem.Comment -> commentHtml(on, muted, opening) + is IssueTimelineItem.Comment -> { + commentHtml(on, muted, opening) + } - is IssueTimelineItem.StateChange -> + is IssueTimelineItem.StateChange -> { event( if (state == IssueState.CLOSED) ThreadIcons.CLOSED else ThreadIcons.REOPENED, when { @@ -40,8 +42,9 @@ private fun IssueTimelineItem.toHtml( }, muted, ) + } - is IssueTimelineItem.LabelChange -> + is IssueTimelineItem.LabelChange -> { event( ThreadIcons.label(label), if (added) { @@ -51,8 +54,9 @@ private fun IssueTimelineItem.toHtml( }, muted, ) + } - is IssueTimelineItem.AssigneeChange -> + is IssueTimelineItem.AssigneeChange -> { event( ThreadIcons.ASSIGNEE, if (added) { @@ -62,8 +66,9 @@ private fun IssueTimelineItem.toHtml( }, muted, ) + } - is IssueTimelineItem.MilestoneChange -> + is IssueTimelineItem.MilestoneChange -> { event( ThreadIcons.MILESTONE, if (added) { @@ -73,15 +78,17 @@ private fun IssueTimelineItem.toHtml( }, muted, ) + } - is IssueTimelineItem.Renamed -> + is IssueTimelineItem.Renamed -> { event( ThreadIcons.RENAME, IssueHubBundle["detail.timeline.renamed", on, escapeHtml(from), escapeHtml(to)], muted, ) + } - is IssueTimelineItem.CrossReferenced -> + is IssueTimelineItem.CrossReferenced -> { event( ThreadIcons.REFERENCE, IssueHubBundle[ @@ -91,8 +98,9 @@ private fun IssueTimelineItem.toHtml( ], muted, ) + } - is IssueTimelineItem.Referenced -> + is IssueTimelineItem.Referenced -> { event( ThreadIcons.COMMIT, IssueHubBundle[ @@ -102,9 +110,11 @@ private fun IssueTimelineItem.toHtml( ], muted, ) + } - is IssueTimelineItem.Unknown -> + is IssueTimelineItem.Unknown -> { event(ThreadIcons.OTHER, IssueHubBundle["detail.timeline.other", on, humanize(kind)], muted) + } } }