From 5de468c5072db175ffd66bc7d3ac5e226fe99f3c Mon Sep 17 00:00:00 2001 From: Vivek Ranjan Date: Fri, 23 Jun 2023 08:59:28 -0400 Subject: [PATCH] "Unified tasks list" tool window to show tasks (#99) * MAK-52 Added: API client for listing tasks * MAK-52 IJ: Show tasks in tool window Added a tool window for IntelliJ plugin to show pending tasks * MAK-52 IJ: Show tasks in tool window Added a tool window for IntelliJ plugin to show pending tasks --- .run/Run Plugin.run.xml | 38 +++ CHANGELOG.md | 12 +- build.gradle.kts | 2 +- .../co/makerflow/client/apis/TasksApi.kt | 6 +- .../makerflow/client/models/CustomTaskTodo.kt | 19 +- .../co/makerflow/client/models/FlowMode.kt | 4 +- .../makerflow/client/models/OnboardingTask.kt | 43 +-- .../client/models/PullRequestTodo.kt | 26 +- .../kotlin/co/makerflow/client/models/Todo.kt | 17 +- .../co/makerflow/client/models/TypedTodo.kt | 78 +++++ .../intellijplugin/panels/DslTasksPanel.kt | 1 + .../intellijplugin/panels/TasksPanel.kt | 268 ++++++++++++++++++ .../panels/TasksToolWindowFactory.kt | 14 + .../intellijplugin/services/TasksService.kt | 59 ++++ src/main/resources/META-INF/plugin.xml | 2 + 15 files changed, 513 insertions(+), 76 deletions(-) create mode 100644 .run/Run Plugin.run.xml create mode 100644 src/main/kotlin/co/makerflow/client/models/TypedTodo.kt create mode 100644 src/main/kotlin/co/makerflow/intellijplugin/panels/DslTasksPanel.kt create mode 100644 src/main/kotlin/co/makerflow/intellijplugin/panels/TasksPanel.kt create mode 100644 src/main/kotlin/co/makerflow/intellijplugin/panels/TasksToolWindowFactory.kt create mode 100644 src/main/kotlin/co/makerflow/intellijplugin/services/TasksService.kt diff --git a/.run/Run Plugin.run.xml b/.run/Run Plugin.run.xml new file mode 100644 index 0000000..7069c04 --- /dev/null +++ b/.run/Run Plugin.run.xml @@ -0,0 +1,38 @@ + + + + + + + + true + true + + + + + false + false + + + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 499f3e5..1641d67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,20 @@ This plugin requires an existing Makerflow account and an + * @return kotlin.collections.List */ @Suppress("UNCHECKED_CAST") - open suspend fun getTodos(source: kotlin.String?): HttpResponse> { + open suspend fun getTodos(source: kotlin.String?): HttpResponse> { val localVariableAuthNames = listOf("api_token") diff --git a/src/main/kotlin/co/makerflow/client/models/CustomTaskTodo.kt b/src/main/kotlin/co/makerflow/client/models/CustomTaskTodo.kt index 70861c5..b7c917f 100644 --- a/src/main/kotlin/co/makerflow/client/models/CustomTaskTodo.kt +++ b/src/main/kotlin/co/makerflow/client/models/CustomTaskTodo.kt @@ -22,6 +22,7 @@ import com.fasterxml.jackson.annotation.JsonProperty /** * A custom task todo * + * @param task * @param sourceType The type of source that the todo is from * @param type The type of todo * @param createdAt Timestamp for when the todo was created @@ -31,21 +32,9 @@ import com.fasterxml.jackson.annotation.JsonProperty data class CustomTaskTodo ( - /* The type of source that the todo is from */ - @field:JsonProperty("sourceType") - val sourceType: kotlin.String? = null, + @field:JsonProperty("task") + val task: CustomTask, - /* The type of todo */ - @field:JsonProperty("type") - val type: kotlin.String? = null, - /* Timestamp for when the todo was created */ - @field:JsonProperty("createdAt") - val createdAt: kotlin.String? = null, - - /* Whether the todo has been completed */ - @field:JsonProperty("done") - val done: kotlin.Boolean? = null - -) +): TypedTodo() diff --git a/src/main/kotlin/co/makerflow/client/models/FlowMode.kt b/src/main/kotlin/co/makerflow/client/models/FlowMode.kt index d02ac35..e517f8b 100644 --- a/src/main/kotlin/co/makerflow/client/models/FlowMode.kt +++ b/src/main/kotlin/co/makerflow/client/models/FlowMode.kt @@ -15,7 +15,7 @@ package co.makerflow.client.models -import co.makerflow.client.models.FlowModeTodo +import co.makerflow.client.models.TypedTodo import com.fasterxml.jackson.annotation.JsonProperty @@ -70,7 +70,7 @@ data class FlowMode ( val taskType: kotlin.String? = null, @field:JsonProperty("todo") - val todo: FlowModeTodo? = null, + val todo: TypedTodo? = null, /* The source of the request that started flow mode */ @field:JsonProperty("start_source") diff --git a/src/main/kotlin/co/makerflow/client/models/OnboardingTask.kt b/src/main/kotlin/co/makerflow/client/models/OnboardingTask.kt index 62813ef..48b8d9f 100644 --- a/src/main/kotlin/co/makerflow/client/models/OnboardingTask.kt +++ b/src/main/kotlin/co/makerflow/client/models/OnboardingTask.kt @@ -19,53 +19,26 @@ package co.makerflow.client.models import com.fasterxml.jackson.annotation.JsonProperty /** - * * - * @param type - * @param sourceType - * @param createdAt - * @param step - * @param done + * + * @param sourceType + * @param type + * @param createdAt + * @param done + * @param step */ data class OnboardingTask ( - @field:JsonProperty("type") - val type: OnboardingTask.Type? = null, - - @field:JsonProperty("sourceType") - val sourceType: OnboardingTask.SourceType? = null, - - @field:JsonProperty("createdAt") - val createdAt: kotlin.String? = null, @field:JsonProperty("step") - val step: OnboardingTask.Step? = null, + val step: OnboardingTask.Step? = null - @field:JsonProperty("done") - val done: kotlin.Boolean? = null - -) { +): TypedTodo() { /** - * * - * Values: onboarding - */ - enum class Type(val value: kotlin.String) { - @JsonProperty(value = "onboarding") onboarding("onboarding"); - } - /** - * - * - * Values: makerflow - */ - enum class SourceType(val value: kotlin.String) { - @JsonProperty(value = "makerflow") makerflow("makerflow"); - } - /** - * * * Values: chatMinusIntegration,repoMinusIntegration,calendarMinusIntegration,cliMinusDownload,editorMinusIntegration,browserMinusExtension */ diff --git a/src/main/kotlin/co/makerflow/client/models/PullRequestTodo.kt b/src/main/kotlin/co/makerflow/client/models/PullRequestTodo.kt index c4cfb48..01a2f47 100644 --- a/src/main/kotlin/co/makerflow/client/models/PullRequestTodo.kt +++ b/src/main/kotlin/co/makerflow/client/models/PullRequestTodo.kt @@ -24,29 +24,21 @@ import com.fasterxml.jackson.annotation.JsonProperty * A pull request that needs to be reviewed by the user * * @param sourceType The type of source that the todo is from - * @param type The type of todo + * @param type * @param createdAt Timestamp for when the todo was created * @param done Whether the todo has been completed + * @param pr + * @param meta */ -data class PullRequestTodo ( +data class PullRequestTodo( - /* The type of source that the todo is from */ - @field:JsonProperty("sourceType") - val sourceType: kotlin.String? = null, + @field:JsonProperty("pr") + val pr: PullRequest? = null, - /* The type of todo */ - @field:JsonProperty("type") - val type: kotlin.String? = null, + @field:JsonProperty("meta") + val meta: PullRequestTodoMeta? = null - /* Timestamp for when the todo was created */ - @field:JsonProperty("createdAt") - val createdAt: kotlin.String? = null, - - /* Whether the todo has been completed */ - @field:JsonProperty("done") - val done: kotlin.Boolean? = null - -) +): TypedTodo() diff --git a/src/main/kotlin/co/makerflow/client/models/Todo.kt b/src/main/kotlin/co/makerflow/client/models/Todo.kt index 622e037..50a2e28 100644 --- a/src/main/kotlin/co/makerflow/client/models/Todo.kt +++ b/src/main/kotlin/co/makerflow/client/models/Todo.kt @@ -32,7 +32,7 @@ data class Todo ( /* The type of source that the todo is from */ @field:JsonProperty("sourceType") - val sourceType: kotlin.String? = null, + val sourceType: Todo.SourceType? = null, /* The type of todo */ @field:JsonProperty("type") @@ -46,5 +46,18 @@ data class Todo ( @field:JsonProperty("done") val done: kotlin.Boolean? = null -) +) { + + /** + * The type of source that the todo is from + * + * Values: slack,github,bitbucket,makerflow + */ + enum class SourceType(val value: kotlin.String) { + @JsonProperty(value = "slack") slack("slack"), + @JsonProperty(value = "github") github("github"), + @JsonProperty(value = "bitbucket") bitbucket("bitbucket"), + @JsonProperty(value = "makerflow") makerflow("makerflow"); + } +} diff --git a/src/main/kotlin/co/makerflow/client/models/TypedTodo.kt b/src/main/kotlin/co/makerflow/client/models/TypedTodo.kt new file mode 100644 index 0000000..5729400 --- /dev/null +++ b/src/main/kotlin/co/makerflow/client/models/TypedTodo.kt @@ -0,0 +1,78 @@ +/** + * + * Please note: + * This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * Do not edit this file manually. + * + */ + +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport" +) + +package co.makerflow.client.models + +import co.makerflow.client.models.CustomTask +import co.makerflow.client.models.CustomTaskTodo +import co.makerflow.client.models.OnboardingTask +import co.makerflow.client.models.PullRequest +import co.makerflow.client.models.PullRequestTodo +import co.makerflow.client.models.PullRequestTodoMeta + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo + +/** + * + * + * @param task + * @param sourceType The type of source that the todo is from + * @param type The type of todo + * @param createdAt Timestamp for when the todo was created + * @param done Whether the todo has been completed + * @param step + * @param pr + * @param meta + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type", visible = true) +@JsonSubTypes( + JsonSubTypes.Type(value = PullRequestTodo::class, name = "bitbucket"), + JsonSubTypes.Type(value = PullRequestTodo::class, name = "github"), + JsonSubTypes.Type(value = CustomTaskTodo::class, name = "makerflow"), + JsonSubTypes.Type(value = OnboardingTask::class, name = "onboarding") +) + +open class TypedTodo() { + + /* The type of source that the todo is from */ + @get:JsonProperty("sourceType") + val sourceType: TypedTodo.SourceType? = null + /* The type of todo */ + @get:JsonProperty("type") + val type: kotlin.String? = null + /* Timestamp for when the todo was created */ + @get:JsonProperty("createdAt") + val createdAt: kotlin.String? = null + /* Whether the todo has been completed */ + @get:JsonProperty("done") + val done: kotlin.Boolean? = null + + /** + * The type of source that the todo is from + * + * Values: slack,github,bitbucket,makerflow + */ + @Suppress("EnumNaming") + enum class SourceType(val value: kotlin.String) { + @JsonProperty(value = "slack") slack("slack"), + @JsonProperty(value = "github") github("github"), + @JsonProperty(value = "bitbucket") bitbucket("bitbucket"), + @JsonProperty(value = "makerflow") makerflow("makerflow"); + } + +} + diff --git a/src/main/kotlin/co/makerflow/intellijplugin/panels/DslTasksPanel.kt b/src/main/kotlin/co/makerflow/intellijplugin/panels/DslTasksPanel.kt new file mode 100644 index 0000000..76f920d --- /dev/null +++ b/src/main/kotlin/co/makerflow/intellijplugin/panels/DslTasksPanel.kt @@ -0,0 +1 @@ +package co.makerflow.intellijplugin.panels diff --git a/src/main/kotlin/co/makerflow/intellijplugin/panels/TasksPanel.kt b/src/main/kotlin/co/makerflow/intellijplugin/panels/TasksPanel.kt new file mode 100644 index 0000000..19dee7d --- /dev/null +++ b/src/main/kotlin/co/makerflow/intellijplugin/panels/TasksPanel.kt @@ -0,0 +1,268 @@ +package co.makerflow.intellijplugin.panels + +import co.makerflow.client.models.CustomTaskTodo +import co.makerflow.client.models.OnboardingTask +import co.makerflow.client.models.OnboardingTask.Step +import co.makerflow.client.models.PullRequestTodo +import co.makerflow.client.models.TypedTodo +import co.makerflow.intellijplugin.services.TasksService +import com.intellij.icons.AllIcons +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.ActionToolbar +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.impl.ActionButton +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.service +import com.intellij.openapi.ui.SimpleToolWindowPanel +import com.intellij.ui.JBColor +import com.intellij.ui.RoundedLineBorder +import com.intellij.ui.components.JBList +import com.intellij.ui.components.JBPanel +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.dsl.builder.panel +import com.intellij.util.concurrency.AppExecutorUtil +import com.intellij.util.ui.JBUI +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toJavaLocalDateTime +import kotlinx.datetime.toLocalDateTime +import org.ocpsoft.prettytime.PrettyTime +import java.awt.Component +import java.awt.FlowLayout +import javax.swing.DefaultListModel +import javax.swing.Icon +import javax.swing.JList +import javax.swing.ListCellRenderer +import javax.swing.border.CompoundBorder + +private const val DELAY_TO_RELOAD_TASKS = 30L + +private const val CONTENT_TOP_OFFSET = 10 + +class TasksPanel : SimpleToolWindowPanel(true) { + + private val fetchTasksCoroutineScope = CoroutineScope(Dispatchers.IO) + + private val listModel: DefaultListModel = DefaultListModel() + private val list: JBList = JBList(listModel).apply { + cellRenderer = TaskListCellRenderer() + border = JBUI.Borders.empty() + } + + private class ReloadAction(val tasksPanel: TasksPanel) : + AnAction("Reload", "Reload tasks", AllIcons.Actions.Refresh) { + override fun actionPerformed(e: AnActionEvent) { + tasksPanel.loadTasks() + } + } + + private val reloadAction = ReloadAction(this) + private val reloadButton = ActionButton( + reloadAction, + reloadAction.templatePresentation, + ActionPlaces.TOOLWINDOW_TOOLBAR_BAR, + ActionToolbar.DEFAULT_MINIMUM_BUTTON_SIZE + ) + + init { + + val content = JBScrollPane(list).apply { + border = JBUI.Borders.emptyTop(CONTENT_TOP_OFFSET) + } + super.setContent(content) + + // Add a reload button to a toolbar for the tool window + val actionToolBar = JBPanel>(FlowLayout(FlowLayout.LEFT)) + actionToolBar.add(reloadButton) + super.setToolbar(actionToolBar) + + ApplicationManager.getApplication().invokeLater { + loadTasks() + } + + AppExecutorUtil.getAppScheduledExecutorService().scheduleWithFixedDelay({ + // Perform reload action + loadTasks() + }, DELAY_TO_RELOAD_TASKS, DELAY_TO_RELOAD_TASKS, java.util.concurrent.TimeUnit.SECONDS) + } + + private fun loadTasks() { + fetchTasksCoroutineScope.launch { + list.setPaintBusy(true) + listModel.clear() + val tasks = service().fetchTasks() + tasks.forEach { + listModel.addElement(it) + } + }.invokeOnCompletion { + list.setPaintBusy(false) + sortTasks() + } + } + + private fun sortTasks() { + val tasks = listModel.elements().toList().sortedBy { it.createdAt } + listModel.clear() + tasks.forEach { listModel.addElement(it) } + } +} + +private const val LIST_ITEM_PADDING_MARGIN = 5 +private const val ROUNDED_CORNER_RADIUS = 5 + +class TaskListCellRenderer : ListCellRenderer { + override fun getListCellRendererComponent( + list: JList?, + value: TypedTodo?, + index: Int, + isSelected: Boolean, + cellHasFocus: Boolean + ): Component { + val taskTitle = getTaskTitle(value) + val taskSubtitle = getSubtitle(value!!) + val link = if ((value is PullRequestTodo && value.pr?.link != null)) { + value.pr.link + } else { + null + } + return panel { + row { + checkBox("").apply { + component.isSelected = value.done ?: false + component.addActionListener { + TODO() + } + } + panel { + row { + icon(getIconForType(value.type!!)) + if (link != null) { + browserLink(taskTitle!!, link) + } else { + label(taskTitle!!) + } + } + separator() + row { + comment(taskSubtitle) + } + } + } + + }.apply { + background = if (isSelected) list?.selectionBackground else list?.background + border = CompoundBorder( + JBUI.Borders.empty(LIST_ITEM_PADDING_MARGIN), // Outer border for padding around each item + CompoundBorder( + // Inner border for the gray line around each item + RoundedLineBorder(JBColor.GRAY, ROUNDED_CORNER_RADIUS), + // Innermost border for left and right margins + JBUI.Borders.empty(0, LIST_ITEM_PADDING_MARGIN) + ) + ) + } + } + + private fun getTaskTitle(value: TypedTodo?) = when (value) { + is PullRequestTodo -> { + "PR #${value.pr?.pullrequestId}: ${value.pr?.pullrequestTitle}" + } + + is CustomTaskTodo -> { + value.task.title + } + + is OnboardingTask -> { + when (value.step!!) { + Step.chatMinusIntegration -> "Connect your Slack workspace" + Step.repoMinusIntegration -> "Connect your code repository" + Step.calendarMinusIntegration -> "Connect your Google account" + Step.cliMinusDownload -> "Install Makerflow CLI" + Step.editorMinusIntegration -> "Install VS Code plugin" + Step.browserMinusExtension -> "Install browser extension" + } + } + + else -> "" + } + + private fun getIconForType(type: String): Icon { + return when (type) { + "github" -> { + AllIcons.Vcs.Vendors.Github + } + + "bitbucket" -> { + AllIcons.Vcs.Merge + } + + "onboarding" -> { + AllIcons.General.Information + } + + "makerflow" -> { + AllIcons.General.TodoDefault + } + + else -> AllIcons.General.TodoDefault + } + } + + private fun getSubtitle(value: TypedTodo): String { + val createdAt: String = if (value !is OnboardingTask) { + " | " + PrettyTime().format( + Instant.parse(value.createdAt!!).toLocalDateTime(TimeZone.currentSystemDefault()) + .toJavaLocalDateTime() + ) + } else { + "" + } + + val sourceDescription = when (value) { + is PullRequestTodo -> { + "${value.pr?.repositoryName} | ${"comments".pluralize(value.meta?.comments!!)} | ${ + "approvals".pluralize( + value.meta.approvals!! + ) + }" + } + + is CustomTaskTodo -> { + "" + } + + is OnboardingTask -> { + when (value.step!!) { + Step.chatMinusIntegration -> "Update your status automatically when you start or end flow mode " + + "or take a break while working." + + Step.repoMinusIntegration -> "See pending pull requests and related information in your " + + "unified task list." + + Step.calendarMinusIntegration -> "See upcoming events and join meetings quickly and easily." + Step.cliMinusDownload -> "Easily access useful Makerflow functionality from the command line." + Step.editorMinusIntegration -> "Easily access useful Makerflow functionality from " + + "your favorite editors." + + Step.browserMinusExtension -> "Block distracting websites when you enter Flow Mode" + } + } + + else -> "" + } + return "$sourceDescription$createdAt" + } +} + +fun String.pluralize(count: Int): String { + val updated = if (count > 1) { + this + 's' + } else { + this + } + return "$count $updated" +} diff --git a/src/main/kotlin/co/makerflow/intellijplugin/panels/TasksToolWindowFactory.kt b/src/main/kotlin/co/makerflow/intellijplugin/panels/TasksToolWindowFactory.kt new file mode 100644 index 0000000..27d8fc5 --- /dev/null +++ b/src/main/kotlin/co/makerflow/intellijplugin/panels/TasksToolWindowFactory.kt @@ -0,0 +1,14 @@ +package co.makerflow.intellijplugin.panels + +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowFactory + +class TasksToolWindowFactory : ToolWindowFactory, DumbAware { + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + val contentManager = toolWindow.contentManager + val content = contentManager.factory.createContent(TasksPanel(), null, true) + contentManager.addContent(content) + } +} diff --git a/src/main/kotlin/co/makerflow/intellijplugin/services/TasksService.kt b/src/main/kotlin/co/makerflow/intellijplugin/services/TasksService.kt new file mode 100644 index 0000000..4d45a4b --- /dev/null +++ b/src/main/kotlin/co/makerflow/intellijplugin/services/TasksService.kt @@ -0,0 +1,59 @@ +package co.makerflow.intellijplugin.services + +import co.makerflow.client.apis.TasksApi +import co.makerflow.client.infrastructure.ApiClient +import co.makerflow.client.models.TypedTodo +import co.makerflow.intellijplugin.settings.SettingsState +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.thisLogger +import com.squareup.moshi.JsonEncodingException +import io.ktor.client.call.NoTransformationFoundException +import io.ktor.serialization.JsonConvertException +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +@Service +class TasksService { + + private val baseUrl = System.getenv("MAKERFLOW_API_URL") ?: ApiClient.BASE_URL + + private fun tasksApi(): TasksApi { + val apiToken = getApiToken() + val api = TasksApi(baseUrl, null, null, ApiClient.JSON_DEFAULT) + api.setApiKey(apiToken) + return api + } + + private fun getApiToken() = System.getenv("MAKERFLOW_API_TOKEN") ?: SettingsState.instance.apiToken + + suspend fun fetchTasks(): List { + return coroutineScope { + var tasks: List = listOf() + launch { + @Suppress("TooGenericExceptionCaught") + try { + val res = tasksApi().getTodos("jetbrains") + if (res.success) { + tasks = res.body() + } + } catch (e: Exception) { + when (e) { + is JsonConvertException, + is NoTransformationFoundException, + is JsonEncodingException -> { + /* + Intentionally left blank + */ + thisLogger().error(e) + thisLogger().error("Error serializing tasks : ${e.message}") + } + + else -> throw e + } + } + }.join() + return@coroutineScope tasks + } + } + +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index ee036d5..d09a25c 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -23,6 +23,8 @@ +