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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 708d1f1..2e89c03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,39 @@ ## [Unreleased] +### Added + +- Issue rows now show the author, creation date, labels and open/closed state instead of just the title. +- Real user avatars for issue authors and assignees, downloaded in the background with generated initials shown until the picture arrives. +- 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. + +### Known limitations + +- Token entry is still a temporary placeholder; there is no dedicated settings/configuration UI yet. +- GitHub is the only supported provider. +- 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 + +- Verified against IntelliJ Platform 2025.3 through 2026.3rc + ## [0.0.3] - 2026-07-24 ### Added @@ -19,8 +52,39 @@ - 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. +### Known limitations### Added + +- Issue rows now show the author, creation date, labels and open/closed state instead of just the title. +- Real user avatars for issue authors and assignees, downloaded in the background with generated initials shown until the picture arrives. +- 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. + ### Known limitations +- Token entry is still a temporary placeholder; there is no dedicated settings/configuration UI yet. +- GitHub is the only supported provider. +- 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 + +- Verified against IntelliJ Platform 2025.3 through 2026.3rc + - 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. @@ -51,4 +115,4 @@ [Unreleased]: https://github.com/vhrabar/IssueHub/compare/v0.0.3...HEAD [0.0.3]: https://github.com/vhrabar/IssueHub/compare/v0.0.2...v0.0.3 -[0.0.2]: https://github.com/vhrabar/IssueHub/commits/v0.0.2 +[0.0.2]: https://github.com/vhrabar/IssueHub/commits/v0.0.2 \ No newline at end of file diff --git a/README.md b/README.md index ab8ae0a..9f2e7d3 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) @@ -6,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 @@ -15,7 +21,13 @@ 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 +- 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 @@ -27,7 +39,18 @@ 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. 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 +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/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 diff --git a/build.gradle.kts b/build.gradle.kts index 2fd9290..85be2af 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,12 +10,12 @@ 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 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 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/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" } } 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..41437e3 --- /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 ac381c0..f8e4573 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,32 @@ data class IssueLabel( val color: String? = null, ) +/** + * A milestone an issue can belong to. + * + * [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 + * 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, @@ -16,18 +42,107 @@ data class Issue( val state: IssueState, val body: String? = null, val labels: List = emptyList(), - val assignee: String? = null, - val assigneeAvatarUrl: String? = null, - val author: String? = null, - val authorAvatarUrl: String? = null, + val assignee: IssueActor? = null, + val milestone: IssueMilestone? = null, + val author: IssueActor? = null, val commentCount: Int = 0, val url: String, val createdAt: String, val updatedAt: String, ) -// placeholder for queries (50 last opened) -data class IssueQuery( - val state: IssueState = IssueState.OPEN, - val limit: Int = 50, +/** + * 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 + * + * [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(), ) + +/** + * 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 +} 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..291d0aa --- /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?.login }, + milestones = issues.mapNotNull { it.milestone }, + authors = issues.mapNotNull { it.author?.login }, + ).mergedWith(IssueFilterOptions()) 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..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,8 @@ 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 import com.intellij.openapi.project.Project @@ -21,6 +23,20 @@ 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() + + /** + * 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 af37a6e..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 @@ -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,19 +40,173 @@ 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 } + } + + /** + * 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") + + /** + * 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?, + ): 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?, + accept: String = ACCEPT_JSON, + 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) .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()) { @@ -52,13 +215,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 +227,54 @@ 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 { + 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 + + 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. */ + 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..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 @@ -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( @@ -27,8 +39,10 @@ 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, val user: GitHubUserDto? = null, val comments: Int = 0, @SerialName("html_url") val htmlUrl: String, @@ -38,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 843633f..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 @@ -1,20 +1,20 @@ 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 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 import kotlin.collections.map -private fun IssueState.toApiParam(): String = - when (this) { - IssueState.OPEN -> "open" - IssueState.CLOSED -> "closed" - IssueState.OTHER -> "all" - } +private fun GitHubUserDto.toActor(): IssueActor = IssueActor(login, avatarUrl) private fun GitHubIssueDto.toIssue(): Issue = Issue( @@ -29,16 +29,112 @@ private fun GitHubIssueDto.toIssue(): Issue = }, body = body, labels = labels.map { IssueLabel(it.name, it.color) }, - assignee = assignee?.login, - assigneeAvatarUrl = assignee?.avatarUrl, - author = user?.login, - authorAvatarUrl = user?.avatarUrl, + assignee = assignee?.toActor(), + milestone = milestone?.let { IssueMilestone(it.number, it.title) }, + author = user?.toActor(), commentCount = comments, url = htmlUrl, createdAt = createdAt, 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() @@ -55,7 +151,49 @@ 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() } + } + + /** + * 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, + issue: Issue, + ): IssueDetail? { + val repo = RepoDetector.detect(project) ?: return null + val token = IssueHubSecrets.getToken(identifier) + val dto = client.fetchIssue(repo, token, issue.id) + 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) + } + + /** + * 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 { 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 e8b99b3..5adb7b9 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 @@ -128,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) } @@ -142,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"])}" + @@ -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, @@ -170,17 +160,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,12 +179,23 @@ 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] - created != null -> IssueHubBundle["issue.meta.created", value.displayNumber, created] - value.author != null -> IssueHubBundle["issue.meta.by", value.displayNumber, value.author] - else -> value.displayNumber + 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 + } } } @@ -304,9 +300,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/IssueDetailPanel.kt b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueDetailPanel.kt new file mode 100644 index 0000000..d6008e4 --- /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 fun preferredFocusComponent(): JComponent = 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/IssueFilterBar.kt b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueFilterBar.kt new file mode 100644 index 0000000..c36732f --- /dev/null +++ b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueFilterBar.kt @@ -0,0 +1,342 @@ +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. + */ +@Suppress("UnstableApiUsage") +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 506fdf6..348979a 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueHubToolWindowFactory.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueHubToolWindowFactory.kt @@ -1,12 +1,15 @@ 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 +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 @@ -36,13 +39,24 @@ import javax.swing.SwingUtilities import javax.swing.ToolTipManager class IssueHubToolWindowFactory : ToolWindowFactory { + // setDisposer has no property form: getDisposer is nullable, setDisposer is not + @Suppress("UnstableApiUsage", "UsePropertyAccessSyntax") override fun createToolWindowContent( project: Project, toolWindow: ToolWindow, ) { 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 + content.setDisposer(panel) toolWindow.contentManager.addContent(content) } @@ -50,7 +64,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) { @@ -101,39 +116,56 @@ 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( object : MouseAdapter() { override fun mouseClicked(e: MouseEvent) { if (e.clickCount == 2) { - issueList.selectedValue?.url?.let { BrowserUtil.browse(it) } + issueList.selectedValue?.let(::openDetail) } } }, ) - 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 + + /** + * 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( 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) { @@ -141,30 +173,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()]) } } } } @@ -184,7 +236,7 @@ class IssueHubToolWindowFactory : ToolWindowFactory { IssueHubBundle["toolWindow.addToken.saved"], IssueHubBundle["toolWindow.addToken.title"], ) - refresh() + refresh(reloadOptions = true) } } } 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..372d6d4 100644 --- a/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueIcons.kt +++ b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueIcons.kt @@ -1,18 +1,23 @@ 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 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. */ @@ -28,13 +33,256 @@ 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( + color: String?, + background: Color, +): Color { + 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. + * + * [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 +293,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 +304,7 @@ internal class IssueStateIcon( private companion object { const val SIZE = 12 - const val DOT = 8 + const val DOT_RATIO = 2.0 / 3.0 } } @@ -112,13 +360,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?, @@ -130,9 +383,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() } @@ -140,6 +393,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 } } @@ -149,13 +405,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?, @@ -168,18 +425,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..6d51ee9 --- /dev/null +++ b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueThreadHtml.kt @@ -0,0 +1,177 @@ +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..1375005 --- /dev/null +++ b/src/main/kotlin/com/github/vhrabar/issuehub/toolWindow/IssueThreadPanel.kt @@ -0,0 +1,206 @@ +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. + */ +@Suppress("UnstableApiUsage") +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 + const val AVATAR_SIZE = CircularAvatarIcon.MAX_SIZE + const val AUTHOR_WIDTH = 150 + const val SCROLL_UNIT = 16 + } +} 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 5d7c973..3602bd4 100644 --- a/src/main/resources/messages/IssueHubBundle.properties +++ b/src/main/resources/messages/IssueHubBundle.properties @@ -8,9 +8,65 @@ 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 + +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 + +# 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= 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) +}