diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 5ef6fbb..0557bac 100644 --- a/gradle/wrapper/gradle-wrapper.jar +++ b/gradle/wrapper/gradle-wrapper.jar @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:91a239400bb638f36a1795d8fdf7939d532cdc7d794d1119b7261aac158b1e60 -size 60756 +oid sha256:55243ef57851f12b070ad14f7f5bb8302daceeebc5bce5ece5fa6edb23e1145c +size 48966 diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsModel.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsModel.kt index 4434b2e..182b375 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsModel.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/AiTestSettingsModel.kt @@ -53,5 +53,7 @@ data class AiTestSettingsModel( val sonarQubePasswordEnv: String = "", val sonarQubeTargetCoverage: String = "80", val sonarQubeMaxFiles: String = "5", - val suppressedGlobalPrompts: List = emptyList() + val suppressedGlobalPrompts: List = emptyList(), + val skillProfilesYaml: String = "", + val suppressedGlobalSkills: List = emptyList() ) diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt index 7afcada..4ee0dc3 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/ContextBoxToolWindowFactory.kt @@ -13,6 +13,7 @@ import com.intellij.openapi.wm.ToolWindowFactory import com.intellij.ui.components.JBScrollPane import com.intellij.ui.content.ContentFactory import kotlinx.coroutines.runBlocking +import java.io.File import java.awt.BorderLayout import java.awt.CardLayout import java.awt.Color @@ -170,6 +171,7 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { val tabs = JTabbedPane().apply { addTab("Context", panel) addTab("Prompt Manager", createPromptManagerPanel(project, bgColor, fgColor, borderColor, commonFont)) + addTab("Skill Manager", createSkillManagerPanel(project, bgColor, fgColor, borderColor, commonFont)) addTab("Sonar Cube", SonarCubeToolWindowPanel.create(project, bgColor, fgColor, borderColor, commonFont)) if (LlmSettingsLoader.loadSettingsModel(project).showLogTab) { addTab("Log", createLogPanel(bgColor, fgColor, borderColor, commonFont)) @@ -423,6 +425,13 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { scopeLabel.foreground = mutedColor contentPreview.text = "" detailsPanel.removeAll() + detailsPanel.add(JLabel("No prompt selected").apply { + foreground = mutedColor + font = promptFont + }, GridBagConstraints().apply { + gridx = 0; gridy = 0; weightx = 1.0; weighty = 1.0 + anchor = GridBagConstraints.CENTER + }) detailsPanel.revalidate() detailsPanel.repaint() return @@ -577,11 +586,9 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { val newPromptButton = JButton("+ New Prompt") val copyButton = JButton("⧉").apply { toolTipText = "Copy prompt content" - margin = Insets(0, 0, 0, 0) - preferredSize = Dimension(36, 30) - minimumSize = preferredSize - maximumSize = preferredSize - font = commonFont.deriveFont(Font.PLAIN, 14f) + margin = Insets(2, 4, 2, 4) + font = promptFont.deriveFont(Font.PLAIN, promptFont.size - 1f) + isFocusPainted = false } searchField.preferredSize = Dimension(searchField.preferredSize.width.coerceAtLeast(220), checkUpdateButton.preferredSize.height) searchField.minimumSize = Dimension(160, checkUpdateButton.preferredSize.height) @@ -785,15 +792,18 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { }, BorderLayout.NORTH) add(JBScrollPane(detailsPanel).apply { border = BorderFactory.createEmptyBorder() }, BorderLayout.CENTER) }, BorderLayout.NORTH) - add(JPanel(BorderLayout(8, 6)).apply { + add(JPanel(BorderLayout()).apply { background = pageColor - add(JLabel("Prompt Content").apply { foreground = fgColor; font = commonFont.deriveFont(16f) }, BorderLayout.WEST) - add(copyButton, BorderLayout.EAST) + add(JPanel(FlowLayout(FlowLayout.LEFT, 4, 0)).apply { + isOpaque = false + border = BorderFactory.createEmptyBorder(6, 0, 8, 0) + add(JLabel("Prompt Content").apply { foreground = fgColor; font = commonFont.deriveFont(16f) }) + add(copyButton) + }, BorderLayout.NORTH) add(JBScrollPane(contentPreview).apply { - preferredSize = Dimension(480, 360) border = BorderFactory.createLineBorder(designBorderColor) viewport.background = cardColor - }, BorderLayout.SOUTH) + }, BorderLayout.CENTER) }, BorderLayout.CENTER) } @@ -898,6 +908,607 @@ class ContextBoxToolWindowFactory : ToolWindowFactory, DumbAware { } } + private enum class SkillSort(val label: String) { + NAME("Sort by: Name"), + UPDATED("Sort by: Updated"); + + override fun toString(): String = label + } + + private data class SkillDefinition( + val name: String, + val content: String, + val isGlobal: Boolean, + val updatedText: String = "—" + ) { + val displayName: String get() = name.removePrefix("global/").replace(Regex("\\s*\\[[^]]+]$"), "") + } + + private sealed class SkillListRow { + data class All(val count: Int) : SkillListRow() + data class SkillRow(val skill: SkillDefinition) : SkillListRow() + object AddSkill : SkillListRow() + } + + private fun createSkillManagerPanel( + project: Project, + bgColor: Color, + fgColor: Color, + borderColor: Color, + commonFont: Font + ): Component { + val usage = ButtonUsageReportService.getInstance(project) + val pageColor = Color(0x0F, 0x17, 0x2A) + val surfaceColor = Color(0x11, 0x1C, 0x2F) + val inputColor = Color(0x0B, 0x12, 0x20) + val accentColor = Color(0x3B, 0x82, 0xF6) + val mutedColor = Color(0x94, 0xA3, 0xB8) + val cardColor = Color(0x14, 0x1F, 0x34) + val designBorderColor = borderColor + val skillFont = UIManager.getFont("EditorPane.font")?.deriveFont(Font.PLAIN, 14f) ?: Font("JetBrains Mono", Font.PLAIN, 14) + val listModel = DefaultListModel() + var selectedSkill: SkillDefinition? = null + + val searchField = JTextField().apply { + toolTipText = "Search skills" + background = inputColor + foreground = fgColor + caretColor = fgColor + border = BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(designBorderColor), + BorderFactory.createEmptyBorder(6, 8, 6, 8) + ) + } + val sortField = JComboBox(SkillSort.entries.toTypedArray()).apply { + background = inputColor + foreground = fgColor + } + val skillList = JList(listModel).apply { + font = commonFont + background = surfaceColor + foreground = fgColor + selectionMode = ListSelectionModel.SINGLE_SELECTION + selectionBackground = accentColor + selectionForeground = Color.WHITE + fixedCellHeight = -1 + cellRenderer = SkillListCellRenderer(surfaceColor, fgColor, mutedColor, designBorderColor, accentColor, commonFont) + } + val nameField = JTextField().apply { + background = inputColor + foreground = fgColor + caretColor = fgColor + border = BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(designBorderColor), + BorderFactory.createEmptyBorder(6, 8, 6, 8) + ) + } + val contentField = JTextArea().apply { + lineWrap = true + wrapStyleWord = true + font = skillFont + background = inputColor + foreground = fgColor + caretColor = fgColor + } + val titleLabel = JLabel("Select a skill").apply { + foreground = fgColor + font = commonFont.deriveFont(Font.PLAIN, 18f) + } + val scopeLabel = JLabel("—").apply { foreground = mutedColor } + val contentPreview = JTextArea().apply { + isEditable = false + lineWrap = true + wrapStyleWord = true + font = skillFont + background = cardColor + foreground = fgColor + caretColor = fgColor + border = BorderFactory.createEmptyBorder(12, 12, 12, 12) + } + val viewCards = JPanel(CardLayout()).apply { background = pageColor } + + fun allSkills(): List { + val model = LlmSettingsLoader.loadSettingsModel(project) + val suppressed = model.suppressedGlobalSkills.toSet() + val items = Yaml().load(model.skillProfilesYaml) as? Map<*, *> ?: emptyMap() + val yamlSkills = items.mapNotNull { (k, v) -> + val key = k?.toString()?.trim().orEmpty() + val value = v?.toString().orEmpty() + if (key.isBlank() || value.isBlank() || key in suppressed) null + else SkillDefinition(key, value, isGlobalSkillName(key), updatedText(key)) + } + val yamlDisplayNames = yamlSkills.map { it.displayName }.toSet() + val localSkills = LlmSettingsLoader.loadLocalSkillFiles() + .filter { (name, _) -> name !in yamlDisplayNames && !isGlobalSkillName(name) && name !in suppressed } + .map { (name, content) -> + SkillDefinition(name, content, false, "—") + } + return localSkills + yamlSkills + } + + fun updatedText(name: String): String { + val match = Regex("\\[([^]]+)]").find(name) + return match?.groupValues?.get(1) ?: "—" + } + + fun showSkillEditor(skill: SkillDefinition?) { + nameField.isEditable = skill?.isGlobal != true + nameField.text = when { + skill == null -> "" + skill.isGlobal -> skill.name + else -> skill.displayName + } + contentField.text = skill?.content.orEmpty() + (viewCards.layout as CardLayout).show(viewCards, "edit") + nameField.requestFocusInWindow() + } + + fun applySkillToDetails(skill: SkillDefinition?) { + selectedSkill = skill + if (skill == null) { + titleLabel.text = "Select a skill" + titleLabel.foreground = fgColor + scopeLabel.text = "" + scopeLabel.foreground = mutedColor + contentPreview.text = "" + return + } + titleLabel.text = skill.displayName + titleLabel.foreground = fgColor + scopeLabel.text = if (skill.isGlobal) "🌐" else "📁" + scopeLabel.foreground = if (skill.isGlobal) Color(0x60, 0xA5, 0xFA) else Color(0x34, 0xD3, 0x99) + contentPreview.text = skill.content + contentPreview.caretPosition = 0 + } + + fun refreshList(select: SkillDefinition? = selectedSkill) { + listModel.removeAllElements() + val query = searchField.text.trim().lowercase() + val sort = sortField.selectedItem as? SkillSort ?: SkillSort.NAME + val skills = allSkills() + .filter { query.isBlank() || it.displayName.lowercase().contains(query) || it.content.lowercase().contains(query) } + .let { list -> + when (sort) { + SkillSort.NAME -> list.sortedBy { it.displayName.lowercase() } + SkillSort.UPDATED -> list.sortedByDescending { it.updatedText } + } + } + listModel.addElement(SkillListRow.All(skills.size)) + skills.forEach { listModel.addElement(SkillListRow.SkillRow(it)) } + listModel.addElement(SkillListRow.AddSkill) + if (select != null) { + for (i in 0 until listModel.size()) { + val row = listModel[i] + if (row is SkillListRow.SkillRow && row.skill.name == select.name) { + skillList.selectedIndex = i + skillList.ensureIndexIsVisible(i) + break + } + } + } + } + + skillList.addListSelectionListener { + if (it.valueIsAdjusting) return@addListSelectionListener + val row = skillList.selectedValue as? SkillListRow ?: return@addListSelectionListener + when (row) { + is SkillListRow.All -> applySkillToDetails(null) + is SkillListRow.SkillRow -> applySkillToDetails(row.skill) + is SkillListRow.AddSkill -> { + selectedSkill = null + showSkillEditor(null) + } + } + } + + searchField.document.addDocumentListener(SimpleDocumentListener { refreshList(null) }) + sortField.addActionListener { refreshList(selectedSkill) } + + fun compactActionButton(text: String, tooltip: String): JButton = JButton(text).apply { + toolTipText = tooltip + margin = Insets(1, 5, 1, 5) + preferredSize = Dimension(28, 26) + minimumSize = preferredSize + font = commonFont.deriveFont(Font.PLAIN, 12f) + } + + val saveButton = JButton("Save") + val cancelButton = JButton("Cancel") + val editButton = compactActionButton("✎", "Edit skill") + val duplicateButton = compactActionButton("⧉", "Duplicate skill") + val deleteButton = compactActionButton("🗑", "Delete or hide skill") + val refreshSkillsButton = compactActionButton("↻", "Refresh skill list") + val checkUpdateButton = JButton("☁ Check Update") + val newSkillButton = JButton("+ New Skill") + val copyButton = JButton("⧉").apply { + toolTipText = "Copy skill content" + margin = Insets(2, 4, 2, 4) + font = skillFont.deriveFont(Font.PLAIN, skillFont.size - 1f) + isFocusPainted = false + } + + searchField.preferredSize = Dimension(searchField.preferredSize.width.coerceAtLeast(220), checkUpdateButton.preferredSize.height) + searchField.minimumSize = Dimension(160, checkUpdateButton.preferredSize.height) + + fun setUpdateControlsEnabled(enabled: Boolean) { + checkUpdateButton.isEnabled = enabled + } + + fun performPullSkillUpdate() { + usage.record("context_box.skill_manager.pull_update") + setUpdateControlsEnabled(false) + ProgressManager.getInstance().run(object : Task.Backgroundable(project, "Pull Skill Updates", true) { + private var status: LlmSettingsLoader.PromptUpdateStatus? = null + + override fun run(indicator: ProgressIndicator) { + indicator.text = "Pulling skill updates..." + status = LlmSettingsLoader.pullBitbucketSkillUpdates(project) + } + + override fun onFinished() { + ApplicationManager.getApplication().invokeLater { + setUpdateControlsEnabled(true) + val s = status ?: return@invokeLater + if (!s.configured) { + Notifications.warn(project, "Skill Manager", s.message) + return@invokeLater + } + if (s.error) { + Notifications.error(project, "Skill Manager", s.message) + return@invokeLater + } + refreshList(selectedSkill) + Notifications.info(project, "Skill Manager", s.message) + } + } + }) + } + + fun handleCheckSkillUpdateStatus(status: LlmSettingsLoader.PromptUpdateStatus) { + if (!status.configured) { + Notifications.warn(project, "Skill Manager", status.message) + return + } + if (status.error) { + Notifications.error(project, "Skill Manager", status.message) + return + } + if (status.hasUpdates) { + val choice = Messages.showYesNoDialog(project, + "${status.message}\n\nUpdate skills now?", "Skill Manager", "Update", "Later", null) + if (choice == Messages.YES) performPullSkillUpdate() else Notifications.info(project, "Skill Manager", status.message) + } else { + Notifications.info(project, "Skill Manager", status.message) + } + } + + checkUpdateButton.addActionListener { + usage.record("context_box.skill_manager.check_update") + setUpdateControlsEnabled(false) + ProgressManager.getInstance().run(object : Task.Backgroundable(project, "Check Skill Updates", true) { + private var status: LlmSettingsLoader.PromptUpdateStatus? = null + + override fun run(indicator: ProgressIndicator) { + indicator.text = "Checking skill updates..." + status = LlmSettingsLoader.checkBitbucketSkillUpdates(project) + } + + override fun onFinished() { + ApplicationManager.getApplication().invokeLater { + setUpdateControlsEnabled(true) + status?.let { handleCheckSkillUpdateStatus(it) } + } + } + }) + } + + refreshSkillsButton.addActionListener { + usage.record("context_box.skill_manager.refresh") + refreshList(selectedSkill) + } + + newSkillButton.addActionListener { + usage.record("context_box.skill_manager.new") + selectedSkill = null + showSkillEditor(null) + } + + editButton.addActionListener { + usage.record("context_box.skill_manager.edit") + showSkillEditor(selectedSkill) + } + + duplicateButton.addActionListener { + usage.record("context_box.skill_manager.duplicate") + val skill = selectedSkill ?: return@addActionListener + selectedSkill = null + nameField.isEditable = true + nameField.text = "${skill.displayName} Copy" + contentField.text = skill.content + (viewCards.layout as CardLayout).show(viewCards, "edit") + } + + copyButton.addActionListener { + val content = selectedSkill?.content.orEmpty() + if (content.isNotBlank()) { + CopyPasteManager.getInstance().setContents(StringSelection(content)) + Notifications.info(project, "Skill Manager", "Skill content copied.") + } + } + + fun dumpYaml(value: Map): String { + val options = DumperOptions().apply { + defaultFlowStyle = DumperOptions.FlowStyle.BLOCK + indent = 2 + isPrettyFlow = true + } + return Yaml(options).dump(value).trimEnd() + } + + saveButton.addActionListener { + usage.record("context_box.skill_manager.save") + val current = selectedSkill + val name = if (current?.isGlobal == true) current.name else nameField.text.trim() + val content = contentField.text.trim() + if (name.isBlank() || content.isBlank()) { + Messages.showErrorDialog(project, "Skill name and content are required.", "Skill Manager") + return@addActionListener + } + val model = LlmSettingsLoader.loadSettingsModel(project) + val items = parseSkillItems(model.skillProfilesYaml) + if (current != null) { + if (current.isGlobal && current.name != name) { + Messages.showErrorDialog(project, "Global skill name cannot be changed.", "Skill Manager") + return@addActionListener + } + items.remove(current.name) + } + if (items.containsKey(name) && (current == null || current.name != name)) { + Messages.showErrorDialog(project, "Skill name already exists.", "Skill Manager") + return@addActionListener + } + items[name] = content + val suppressed = if (isGlobalSkillName(name)) model.suppressedGlobalSkills.filter { it != name } else model.suppressedGlobalSkills + val updated = model.copy( + skillProfilesYaml = dumpYaml(items), + suppressedGlobalSkills = suppressed.distinct().sorted() + ) + LlmSettingsLoader.saveSettingsModel(project, updated) + val saved = SkillDefinition(name, content, isGlobalSkillName(name), updatedText(name)) + applySkillToDetails(saved) + refreshList(saved) + (viewCards.layout as CardLayout).show(viewCards, "view") + } + + deleteButton.addActionListener { + usage.record("context_box.skill_manager.delete") + val skill = selectedSkill + if (skill == null) { + Messages.showErrorDialog(project, "Please select a skill first.", "Skill Manager") + return@addActionListener + } + val actionDescription = if (skill.isGlobal) "hide this global skill locally" else "delete this local skill" + val confirm = Messages.showYesNoDialog(project, + "Are you sure you want to $actionDescription?", "Skill Manager", "Delete", "Cancel", null) + if (confirm != Messages.YES) return@addActionListener + val model = LlmSettingsLoader.loadSettingsModel(project) + val items = parseSkillItems(model.skillProfilesYaml) + val inYaml = items.containsKey(skill.name) + items.remove(skill.name) + val suppressed = if (skill.isGlobal) { + model.suppressedGlobalSkills + skill.name + } else if (!inYaml) { + val safeName = skill.displayName.replace(Regex("[\\\\/:*?\"<>|]"), "_") + File(LlmSettingsLoader.skillsDir(), "$safeName.md").delete() + model.suppressedGlobalSkills + } else { + model.suppressedGlobalSkills + } + val updated = model.copy( + skillProfilesYaml = dumpYaml(items), + suppressedGlobalSkills = suppressed.distinct().sorted() + ) + LlmSettingsLoader.saveSettingsModel(project, updated) + selectedSkill = null + refreshList(null) + applySkillToDetails(null) + (viewCards.layout as CardLayout).show(viewCards, "view") + Notifications.info(project, "Skill Manager", if (skill.isGlobal) "Global skill hidden locally." else "Skill deleted.") + } + + cancelButton.addActionListener { + nameField.isEditable = true + (viewCards.layout as CardLayout).show(viewCards, "view") + } + + val viewPanel = JPanel(BorderLayout(0, 12)).apply { + background = pageColor + add(JPanel(BorderLayout()).apply { + background = pageColor + add(JPanel(FlowLayout(FlowLayout.LEFT, 4, 0)).apply { + isOpaque = false + border = BorderFactory.createEmptyBorder(6, 0, 8, 0) + add(JLabel("Skill Content").apply { foreground = fgColor; font = commonFont.deriveFont(16f) }) + add(copyButton) + }, BorderLayout.NORTH) + add(JBScrollPane(contentPreview).apply { + border = BorderFactory.createLineBorder(designBorderColor) + viewport.background = cardColor + }, BorderLayout.CENTER) + }, BorderLayout.CENTER) + } + + val editPanel = JPanel(GridBagLayout()).apply { + background = surfaceColor + border = BorderFactory.createLineBorder(designBorderColor) + val gbc = GridBagConstraints().apply { + insets = Insets(6, 6, 6, 6) + fill = GridBagConstraints.HORIZONTAL + anchor = GridBagConstraints.WEST + } + fun addLabel(row: Int, text: String) { + gbc.gridx = 0; gbc.gridy = row; gbc.weightx = 0.0 + add(JLabel(text).apply { foreground = mutedColor }, gbc) + } + addLabel(0, "Name") + gbc.gridx = 1; gbc.gridy = 0; gbc.weightx = 1.0 + add(nameField, gbc) + addLabel(1, "Content") + gbc.gridx = 1; gbc.gridy = 1 + gbc.fill = GridBagConstraints.BOTH; gbc.weighty = 1.0 + add(JBScrollPane(contentField).apply { preferredSize = Dimension(480, 360) }, gbc) + gbc.gridx = 1; gbc.gridy = 2 + gbc.fill = GridBagConstraints.HORIZONTAL; gbc.weighty = 0.0 + add(JPanel(FlowLayout(FlowLayout.LEFT, 8, 0)).apply { + isOpaque = false + add(saveButton) + add(cancelButton) + }, gbc) + } + viewCards.add(viewPanel, "view") + viewCards.add(editPanel, "edit") + + val leftPanel = JPanel(BorderLayout(0, 8)).apply { + background = pageColor + preferredSize = Dimension(470, 600) + border = BorderFactory.createEmptyBorder(10, 10, 10, 10) + add(JPanel(BorderLayout(8, 8)).apply { + background = surfaceColor + add(JLabel("Skill Manager").apply { foreground = fgColor; font = commonFont.deriveFont(16f) }, BorderLayout.NORTH) + add(searchField, BorderLayout.CENTER) + add(JPanel(FlowLayout(FlowLayout.RIGHT, 6, 0)).apply { + isOpaque = false + add(checkUpdateButton) + add(newSkillButton) + }, BorderLayout.EAST) + }, BorderLayout.NORTH) + add(JPanel(BorderLayout(8, 8)).apply { + background = pageColor + add(JPanel(FlowLayout(FlowLayout.LEFT, 8, 0)).apply { + isOpaque = false + add(refreshSkillsButton) + add(sortField) + }, BorderLayout.NORTH) + add(JBScrollPane(skillList).apply { + border = BorderFactory.createLineBorder(designBorderColor) + viewport.background = surfaceColor + }, BorderLayout.CENTER) + }, BorderLayout.CENTER) + } + + val rightPanel = JPanel(BorderLayout(0, 12)).apply { + background = pageColor + border = BorderFactory.createEmptyBorder(18, 18, 18, 18) + add(JPanel(BorderLayout(12, 0)).apply { + background = pageColor + add(JPanel(BorderLayout(8, 0)).apply { + isOpaque = false + add(titleLabel, BorderLayout.CENTER) + add(scopeLabel, BorderLayout.EAST) + }, BorderLayout.CENTER) + add(JPanel(FlowLayout(FlowLayout.RIGHT, 8, 0)).apply { + isOpaque = false + add(editButton) + add(duplicateButton) + add(deleteButton) + }, BorderLayout.EAST) + }, BorderLayout.NORTH) + add(viewCards, BorderLayout.CENTER) + } + + refreshList(null) + applySkillToDetails(null) + return JSplitPane(JSplitPane.HORIZONTAL_SPLIT, leftPanel, rightPanel).apply { + resizeWeight = 0.38 + dividerSize = 1 + border = BorderFactory.createEmptyBorder() + background = pageColor + } + } + + private inner class SkillListCellRenderer( + private val bgColor: Color, + private val fgColor: Color, + private val mutedColor: Color, + private val borderColor: Color, + private val accentColor: Color, + private val commonFont: Font + ) : DefaultListCellRenderer() { + override fun getListCellRendererComponent( + list: JList<*>, + value: Any?, + index: Int, + isSelected: Boolean, + cellHasFocus: Boolean + ): Component { + val panel = JPanel(BorderLayout(8, 0)).apply { + background = if (isSelected) accentColor else bgColor + border = BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(0, 0, 1, 0, borderColor), + BorderFactory.createEmptyBorder(8, 10, 8, 10) + ) + } + val textColor = if (isSelected) Color.WHITE else fgColor + when (val row = value as? SkillListRow) { + is SkillListRow.All -> { + panel.add(JLabel("☁ All").apply { + foreground = textColor + font = commonFont.deriveFont(Font.BOLD, 14f) + }, BorderLayout.WEST) + panel.add(JLabel("${row.count}").apply { + foreground = mutedColor + font = commonFont.deriveFont(Font.PLAIN, 12f) + }, BorderLayout.EAST) + } + is SkillListRow.SkillRow -> { + val icon = if (row.skill.isGlobal) "🌐" else "📁" + val nameLabel = JLabel("${icon} ${row.skill.displayName}").apply { + foreground = textColor + font = commonFont.deriveFont(Font.PLAIN, 14f) + } + val updatedLabel = JLabel(row.skill.updatedText).apply { + foreground = mutedColor + font = commonFont.deriveFont(Font.PLAIN, 11f) + } + val leftPanel = JPanel(BorderLayout(0, 2)).apply { + isOpaque = false + add(nameLabel, BorderLayout.NORTH) + add(updatedLabel, BorderLayout.SOUTH) + } + panel.add(leftPanel, BorderLayout.CENTER) + } + is SkillListRow.AddSkill -> { + panel.add(JLabel("+ Add Skill").apply { + foreground = mutedColor + font = commonFont.deriveFont(Font.ITALIC, 13f) + }, BorderLayout.CENTER) + } + null -> {} + } + return panel + } + } + + private fun parseSkillItems(yaml: String): LinkedHashMap { + val parsed = Yaml().load(yaml) as? Map<*, *> ?: emptyMap() + val result = LinkedHashMap() + parsed.forEach { (k, v) -> + val key = k?.toString()?.trim().orEmpty() + val value = v?.toString().orEmpty() + if (key.isNotBlank() && value.isNotBlank()) result[key] = value + } + return result + } + + private fun isGlobalSkillName(name: String): Boolean { + return name.startsWith("[ADA]") || name.startsWith("[repo]") || name.startsWith("global/") + } + + private fun updatedText(name: String): String { + val match = Regex("\\[([^]]+)]").find(name) + return match?.groupValues?.get(1) ?: "—" + } + private inner class PromptListCellRenderer( private val bgColor: Color, private val fgColor: Color, diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GenerateCommitMessageAction.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GenerateCommitMessageAction.kt index 9ce1053..5919c5c 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GenerateCommitMessageAction.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GenerateCommitMessageAction.kt @@ -6,7 +6,9 @@ import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.progress.ProgressIndicator import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.progress.Task +import com.intellij.openapi.vcs.FilePath import com.intellij.openapi.vcs.VcsDataKeys +import com.intellij.openapi.vcs.changes.Change class GenerateCommitMessageAction : AnAction("Generate Commit Message") { @@ -18,6 +20,11 @@ class GenerateCommitMessageAction : AnAction("Generate Commit Message") { Notifications.error(project, "Generate Commit Message", "Commit message box is not available.") return } + + val workflowUi = e.getData(VcsDataKeys.COMMIT_WORKFLOW_UI) + val includedChanges = getIncludedChanges(workflowUi) + val includedUnversioned = getIncludedUnversionedFiles(workflowUi) + val selectedPrompt = LlmSettingsLoader.loadConfig(project).prompts.profiles.commitMessage.selected ButtonUsageReportService.getInstance(project).recordPromptUsage("commit.message", selectedPrompt) @@ -25,10 +32,10 @@ class GenerateCommitMessageAction : AnAction("Generate Commit Message") { override fun run(indicator: ProgressIndicator) { try { indicator.text = "Collecting git diff..." - val diff = GitDiffProvider.getDiff(project) + val diff = GitDiffProvider.getDiffForSelectedChanges(project, includedChanges, includedUnversioned) if (diff.isBlank()) { - Notifications.error(project, "Generate Commit Message", "No local git changes found.") + Notifications.error(project, "Generate Commit Message", "No selected changes to commit.") return } @@ -37,7 +44,6 @@ class GenerateCommitMessageAction : AnAction("Generate Commit Message") { ApplicationManager.getApplication().invokeLater { commitMessageUi.setCommitMessage(message.trim()) -// commitMessageUi.focus() } } catch (ex: Exception) { Notifications.error( @@ -54,4 +60,26 @@ class GenerateCommitMessageAction : AnAction("Generate Commit Message") { e.presentation.isEnabledAndVisible = e.project != null && e.getData(VcsDataKeys.COMMIT_MESSAGE_CONTROL) != null } + + @Suppress("UNCHECKED_CAST") + private fun getIncludedChanges(workflowUi: Any?): List { + if (workflowUi == null) return emptyList() + return try { + val method = workflowUi.javaClass.getMethod("getIncludedChanges") + (method.invoke(workflowUi) as? List<*>)?.filterIsInstance().orEmpty() + } catch (_: Exception) { + emptyList() + } + } + + @Suppress("UNCHECKED_CAST") + private fun getIncludedUnversionedFiles(workflowUi: Any?): List { + if (workflowUi == null) return emptyList() + return try { + val method = workflowUi.javaClass.getMethod("getIncludedUnversionedFiles") + (method.invoke(workflowUi) as? List<*>)?.filterIsInstance().orEmpty() + } catch (_: Exception) { + emptyList() + } + } } diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GenerateCommitMessagePromptMenuAction.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GenerateCommitMessagePromptMenuAction.kt index 195d962..d50ab13 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GenerateCommitMessagePromptMenuAction.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GenerateCommitMessagePromptMenuAction.kt @@ -9,7 +9,9 @@ import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.progress.Task import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project +import com.intellij.openapi.vcs.FilePath import com.intellij.openapi.vcs.VcsDataKeys +import com.intellij.openapi.vcs.changes.Change class GenerateCommitMessagePromptMenuAction : ActionGroup("Generate Commit Message (Choose Prompt)", true), DumbAware { init { @@ -44,6 +46,10 @@ private class GenerateCommitMessageByPromptAction( val project = e.project ?: return val commitMessageUi = e.getData(VcsDataKeys.COMMIT_MESSAGE_CONTROL) ?: return + val workflowUi = e.getData(VcsDataKeys.COMMIT_WORKFLOW_UI) + val includedChanges = getIncludedChanges(workflowUi) + val includedUnversioned = getIncludedUnversionedFiles(workflowUi) + saveDefaultCommitPromptProfile(project, promptName) ButtonUsageReportService.getInstance(project).recordPromptUsage("commit.message", promptName) @@ -51,10 +57,10 @@ private class GenerateCommitMessageByPromptAction( override fun run(indicator: ProgressIndicator) { try { indicator.text = "Collecting git diff..." - val diff = GitDiffProvider.getDiff(project) + val diff = GitDiffProvider.getDiffForSelectedChanges(project, includedChanges, includedUnversioned) if (diff.isBlank()) { - Notifications.error(project, "Generate Commit Message", "No local git changes found.") + Notifications.error(project, "Generate Commit Message", "No selected changes to commit.") return } @@ -78,6 +84,28 @@ private class GenerateCommitMessageByPromptAction( }) } + @Suppress("UNCHECKED_CAST") + private fun getIncludedChanges(workflowUi: Any?): List { + if (workflowUi == null) return emptyList() + return try { + val method = workflowUi.javaClass.getMethod("getIncludedChanges") + (method.invoke(workflowUi) as? List<*>)?.filterIsInstance().orEmpty() + } catch (_: Exception) { + emptyList() + } + } + + @Suppress("UNCHECKED_CAST") + private fun getIncludedUnversionedFiles(workflowUi: Any?): List { + if (workflowUi == null) return emptyList() + return try { + val method = workflowUi.javaClass.getMethod("getIncludedUnversionedFiles") + (method.invoke(workflowUi) as? List<*>)?.filterIsInstance().orEmpty() + } catch (_: Exception) { + emptyList() + } + } + private fun saveDefaultCommitPromptProfile(project: Project, selectedName: String) { val current = LlmSettingsLoader.loadSettingsModel(project) if (current.commitPromptProfileDefault == selectedName) return diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GitDiffProvider.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GitDiffProvider.kt index e526e80..34699ef 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GitDiffProvider.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/GitDiffProvider.kt @@ -1,46 +1,63 @@ package org.openprojectx.ai.plugin import com.intellij.openapi.project.Project +import com.intellij.openapi.vcs.FilePath +import com.intellij.openapi.vcs.changes.Change import git4idea.repo.GitRepositoryManager import java.io.File object GitDiffProvider { - fun getDiff(project: Project): String { - val basePath = project.basePath ?: error("Project base path is unavailable") + fun getDiffForSelectedChanges(project: Project, changes: List, unversionedFiles: List): String { val repo = GitRepositoryManager.getInstance(project).repositories.firstOrNull() ?: error("No Git repository found for project") + val repoRoot = repo.root.path + + fun toRelativePath(absolutePath: String): String? { + val f = File(absolutePath) + val canonical = if (f.isAbsolute) f.canonicalPath else File(repoRoot, absolutePath).canonicalPath + return if (canonical != null && canonical.startsWith(repoRoot)) { + canonical.removePrefix(repoRoot).removePrefix("/").removePrefix("\\") + } else null + } - val process = ProcessBuilder( - "git", - "diff", - "--cached", - "--", - "." + val filePaths = changes.mapNotNull { change -> + val revision = change.afterRevision ?: change.beforeRevision + revision?.file?.path?.let { toRelativePath(it) } + } + unversionedFiles.mapNotNull { it.path?.let { p -> toRelativePath(p) } } + val uniquePaths = filePaths.distinct() + + if (uniquePaths.isEmpty()) return "" + + val stagedProcess = ProcessBuilder( + mutableListOf("git", "diff", "--cached", "--") + uniquePaths ) .directory(File(repo.root.path)) .redirectErrorStream(true) .start() - val staged = process.inputStream.bufferedReader().use { it.readText() } - process.waitFor() + val staged = stagedProcess.inputStream.bufferedReader().use { it.readText() } + stagedProcess.waitFor() - if (staged.isNotBlank()) return staged - - val workingTreeProcess = ProcessBuilder( - "git", - "diff", - "--", - "." + val unstagedProcess = ProcessBuilder( + mutableListOf("git", "diff", "--") + uniquePaths ) .directory(File(repo.root.path)) .redirectErrorStream(true) .start() - val unstaged = workingTreeProcess.inputStream.bufferedReader().use { it.readText() } - workingTreeProcess.waitFor() - - return unstaged + val unstaged = unstagedProcess.inputStream.bufferedReader().use { it.readText() } + unstagedProcess.waitFor() + + return buildString { + if (staged.isNotBlank()) { + appendLine(staged.trimEnd()) + } + if (unstaged.isNotBlank()) { + if (isNotEmpty()) appendLine() + append(unstaged.trimEnd()) + } + } } diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmSettingsLoader.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmSettingsLoader.kt index dacc153..510f3ae 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmSettingsLoader.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/LlmSettingsLoader.kt @@ -222,7 +222,7 @@ object LlmSettingsLoader { validateBitbucketPromptRepoConnection(project, remoteConfig) val remoteEntries = fetchBitbucketGlobalPromptEntries(project, remoteConfig, strict = true) val visibleRemoteEntries = remoteEntries.filterNot { - globalPromptSuppressionKey(it.category, it.cacheKey) in model.suppressedGlobalPrompts + isPromptGloballySuppressed(it.category, it.cacheKey, model.suppressedGlobalPrompts) } val remoteGlobalKeys = visibleRemoteEntries .map { it.cacheKey } @@ -289,7 +289,7 @@ object LlmSettingsLoader { val suppressedGlobalPrompts = parseSuppressedGlobalPrompts(prompts) val visibleRemoteEntries = remoteEntries.filterNot { - globalPromptSuppressionKey(it.category, it.cacheKey) in suppressedGlobalPrompts + isPromptGloballySuppressed(it.category, it.cacheKey, suppressedGlobalPrompts) } val remoteGlobalKeys = visibleRemoteEntries.map { it.cacheKey }.toSet() val cachedGlobalKeys = readCachedGlobalPromptKeys(project) @@ -320,6 +320,108 @@ object LlmSettingsLoader { } } + fun checkBitbucketSkillUpdates(project: Project): PromptUpdateStatus { + val model = loadSettingsModel(project) + val remoteConfig = BitbucketPromptRepoConfig( + enabled = model.bitbucketPromptRepoEnabled, + repoUrl = model.bitbucketPromptRepoUrl, + branch = model.bitbucketPromptRepoBranch, + token = model.bitbucketPromptRepoToken, + username = model.bitbucketPromptRepoUsername, + password = model.bitbucketPromptRepoPassword, + configImportPath = model.bitbucketConfigImportPath + ) + if (remoteConfig.repoUrl.isBlank()) { + return PromptUpdateStatus( + configured = false, + remoteCount = 0, + cachedCount = 0, + hasUpdates = false, + message = "Skill repo URL is not configured." + ) + } + return runCatching { + validateBitbucketPromptRepoConnection(project, remoteConfig) + val remoteEntries = fetchBitbucketSkillEntries(project, remoteConfig) + val remoteKeys = remoteEntries.map { it.cacheKey }.toSet() + val cachedKeys = readCachedGlobalSkillKeys(project) + PromptUpdateStatus( + configured = true, + remoteCount = remoteKeys.size, + cachedCount = cachedKeys.size, + hasUpdates = remoteKeys != cachedKeys, + message = if (remoteEntries.isEmpty()) { + "Skill repo is reachable, but no skill markdown files were found under skill(s)/ directories." + } else { + "Found ${remoteEntries.size} skill(s) in remote repo. Latest update: ${remoteEntries.maxOfOrNull { it.updatedAt } ?: Instant.EPOCH}" + } + ) + }.getOrElse { ex -> + PromptUpdateStatus( + configured = true, + remoteCount = 0, + cachedCount = 0, + hasUpdates = false, + message = ex.message ?: ex.toString(), + error = true + ) + } + } + + fun pullBitbucketSkillUpdates(project: Project): PromptUpdateStatus { + val root = readRootMap(project) + val model = loadSettingsModel(project) + val remoteConfig = BitbucketPromptRepoConfig( + enabled = model.bitbucketPromptRepoEnabled, + repoUrl = model.bitbucketPromptRepoUrl, + branch = model.bitbucketPromptRepoBranch, + token = model.bitbucketPromptRepoToken, + username = model.bitbucketPromptRepoUsername, + password = model.bitbucketPromptRepoPassword, + configImportPath = model.bitbucketConfigImportPath + ) + if (remoteConfig.repoUrl.isBlank()) { + return PromptUpdateStatus( + configured = false, + remoteCount = 0, + cachedCount = 0, + hasUpdates = false, + message = "Skill repo URL is not configured." + ) + } + return runCatching { + RuntimeLogStore.append("INFO | Skill Repo | Pull start repoUrl=${remoteConfig.repoUrl} branch=${remoteConfig.branch}") + validateBitbucketPromptRepoConnection(project, remoteConfig) + val remoteEntries = fetchBitbucketSkillEntries(project, remoteConfig, strict = true) + writeGlobalSkillCache(project, root, remoteEntries) + writeSkillMarkdownFiles(loadSettingsModel(project)) + val remoteKeys = remoteEntries.map { it.cacheKey }.toSet() + val cachedKeys = readCachedGlobalSkillKeys(project) + RuntimeLogStore.append("INFO | Skill Repo | Pull completed remoteCount=${remoteKeys.size} cachedCount=${cachedKeys.size}") + PromptUpdateStatus( + configured = true, + remoteCount = remoteKeys.size, + cachedCount = cachedKeys.size, + hasUpdates = false, + message = if (remoteEntries.isEmpty()) { + "Skill repo is reachable, but no skill markdown files were found under skill(s)/ directories." + } else { + "Skill cache updated from remote repo. Latest remote update: ${remoteEntries.maxOfOrNull { it.updatedAt } ?: Instant.EPOCH}" + } + ) + }.getOrElse { ex -> + RuntimeLogStore.append("ERROR | Skill Repo | Pull failed: ${ex.message ?: ex}") + PromptUpdateStatus( + configured = true, + remoteCount = 0, + cachedCount = 0, + hasUpdates = false, + message = ex.message ?: ex.toString(), + error = true + ) + } + } + fun checkBitbucketHardcodedPath(project: Project, rawUrl: String): String { val target = rawUrl.trim() if (target.isBlank()) error("Hardcoded Bitbucket path is empty.") @@ -464,7 +566,15 @@ object LlmSettingsLoader { sonarQubePasswordEnv = sonarQube.string("passwordEnv"), sonarQubeTargetCoverage = sonarQube.string("targetCoverage").ifBlank { "80" }, sonarQubeMaxFiles = sonarQube.string("maxFiles").ifBlank { "5" }, - suppressedGlobalPrompts = suppressedGlobalPrompts + suppressedGlobalPrompts = suppressedGlobalPrompts, + skillProfilesYaml = dumpPromptProfilesYaml( + parsePromptProfileItems( + (root["skills"] as? Map<*, *>)?.map("items") ?: emptyMap(), + "", + includeDefault = false + ) + ), + suppressedGlobalSkills = parseSuppressedGlobalItems(root["skills"] as? Map<*, *>) ) } @@ -475,7 +585,9 @@ object LlmSettingsLoader { root["sonarQube"] = buildSonarQubeMap(model) root.remove("generation") root["prompts"] = buildPromptsMap(project, root["prompts"] as? Map<*, *>, model) + root["skills"] = buildSkillsMap(model) writeRootMap(project, root) + writeSkillMarkdownFiles(model) } fun saveLlmSettingsModel(project: Project, model: AiTestSettingsModel) { @@ -885,6 +997,80 @@ object LlmSettingsLoader { return prompts } + private fun buildSkillsMap(model: AiTestSettingsModel): Map { + val items = parsePromptProfileItems( + Yaml().load(model.skillProfilesYaml) as? Map<*, *> ?: emptyMap(), + "", + includeDefault = false + ) + return linkedMapOf( + "selected" to (if (items.isNotEmpty()) items.keys.first() else "default"), + "items" to items, + "suppressed" to model.suppressedGlobalSkills.sorted() + ) + } + + fun skillsDir(): File = File(configHomeDir(), "skills").also { it.mkdirs() } + + fun loadLocalSkillFiles(): List> { + val dir = skillsDir() + if (!dir.exists()) return emptyList() + return dir.listFiles() + ?.filter { it.isFile && it.extension == "md" } + ?.mapNotNull { file -> + val text = file.readText(Charsets.UTF_8).trim() + if (text.isBlank()) return@mapNotNull null + val name = extractSkillName(text) ?: file.nameWithoutExtension + val body = extractSkillBody(text) + name to body + } + .orEmpty() + } + + private fun extractSkillName(content: String): String? { + val match = Regex("""(?im)^name\s*:\s*(.+)$""").find(content) + return match?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() } + } + + private fun writeSkillMarkdownFiles(model: AiTestSettingsModel) { + val items = parsePromptProfileItems( + Yaml().load(model.skillProfilesYaml) as? Map<*, *> ?: emptyMap(), + "", + includeDefault = false + ) + val dir = skillsDir() + dir.listFiles()?.forEach { if (it.isFile && it.extension == "md") it.delete() } + items.forEach { (name, content) -> + val displayName = name.removePrefix("global/").replace(Regex("\\s*\\[[^]]+]$"), "") + val safeFileName = displayName.replace(Regex("[\\\\/:*?\"<>|]"), "_") + val body = extractSkillBody(content) + val file = File(dir, "$safeFileName.md") + buildSkillMarkdown(name, body).let { file.writeText(it, Charsets.UTF_8) } + } + } + + private fun extractSkillBody(content: String): String { + val lines = content.trim().lines() + val metadataKeys = setOf("name", "type", "time", "updatedAt", "pulledAt", "description") + val bodyStart = lines.indexOfFirst { line -> + val key = line.substringBefore(":").trim().lowercase() + key !in metadataKeys && line.isNotBlank() + } + if (bodyStart < 0) return content + return lines.drop(bodyStart).joinToString("\n").trim() + } + + private fun buildSkillMarkdown(name: String, body: String): String { + return buildString { + appendLine("---") + appendLine("name: $name") + appendLine("description: $name") + appendLine("---") + appendLine() + appendLine(body.trim()) + } + } + private fun writeGlobalPromptCache(project: Project, root: Map, remoteEntries: List) { val mutableRoot = root.toMutableLinkedMap() val prompts = (mutableRoot["prompts"] as? Map<*, *>).toMutableLinkedMap() @@ -898,7 +1084,7 @@ object LlmSettingsLoader { remoteEntries .filter { it.category == target.category } - .filterNot { globalPromptSuppressionKey(it.category, it.cacheKey) in suppressedGlobalPrompts } + .filterNot { isPromptGloballySuppressed(it.category, it.cacheKey, suppressedGlobalPrompts) } .sortedWith(compareByDescending { it.updatedAt }.thenBy { it.name }) .forEach { meta -> itemsWithoutOldGlobal[meta.cacheKey] = ensurePromptUpdateMetadata(meta) @@ -962,7 +1148,7 @@ object LlmSettingsLoader { suppressedGlobalPrompts: Collection = emptyList() ): Map { val globalPrompts = loadGlobalPrompts(project, remoteRepoConfig)[category].orEmpty() - .filterKeys { globalPromptSuppressionKey(category, it) !in suppressedGlobalPrompts } + .filterKeys { !isPromptGloballySuppressed(category, it, suppressedGlobalPrompts) } if (globalPrompts.isEmpty()) return items val normalized = linkedMapOf() globalPrompts.forEach { (name, content) -> @@ -1566,7 +1752,20 @@ object LlmSettingsLoader { .distinct() } - private fun globalPromptSuppressionKey(category: String, name: String): String = "$category:$name" + private fun parseSuppressedGlobalItems(section: Map<*, *>?): List { + return (section?.get("suppressed") as? List<*>) + .orEmpty() + .mapNotNull { it?.toString()?.trim()?.takeIf { value -> value.isNotBlank() } } + .distinct() + } + + private fun isPromptGloballySuppressed(category: String, cacheKey: String, suppressed: Collection): Boolean { + if (suppressed.isEmpty()) return false + val fullKey = "$category:$cacheKey" + if (fullKey in suppressed) return true + val displayName = cacheKey.removePrefix("global/").replace(Regex("\\s*\\[[^]]+]$"), "") + return "$category:$displayName" in suppressed + } private fun readCachedGlobalPromptKeys(project: Project): Set { val root = readRootMap(project) @@ -1586,4 +1785,135 @@ object LlmSettingsLoader { .filter { it.startsWith("global/") } .toSet() } + + private fun isSkillMarkdownPath(path: String): Boolean { + val normalized = path.replace('\\', '/').lowercase() + return (normalized.startsWith("skills/") || normalized.startsWith("skill/")) && normalized.endsWith(".md") + } + + private fun fetchBitbucketSkillEntries(project: Project, config: BitbucketPromptRepoConfig, strict: Boolean = false): List { + if (config.repoUrl.isBlank()) return emptyList() + val result = runCatching { + val directBitbucketRawBaseUrl = parseDirectBitbucketRawBaseUrl(config.repoUrl) + val repo = directBitbucketRawBaseUrl?.let { + RepositoryRef( + provider = GitHostingProviderType.BITBUCKET, + host = "", + projectKey = "", + repoSlug = "" + ) + } ?: GitRemoteParser.parse(config.repoUrl) + val filePaths = when (repo.provider) { + GitHostingProviderType.BITBUCKET -> if (directBitbucketRawBaseUrl != null) { + loadBitbucketSkillFilePathsFromRawBaseUrl(project, directBitbucketRawBaseUrl, config.branch, config) + } else { + loadBitbucketSkillFilePaths(project, repo.host, repo.projectKey, repo.repoSlug, config.branch, config) + } + GitHostingProviderType.GITHUB -> loadGitHubSkillFilePaths(repo.host, repo.projectKey, repo.repoSlug, config.branch, config.token) + else -> emptyList() + } + filePaths.mapNotNull { path -> + val content = when (repo.provider) { + GitHostingProviderType.BITBUCKET -> if (directBitbucketRawBaseUrl != null) { + loadBitbucketPromptRawFromBaseUrl(project, directBitbucketRawBaseUrl, path, config.branch, config) + } else { + loadBitbucketPromptRaw(project, repo.host, repo.projectKey, repo.repoSlug, path, config.branch, config) + } + GitHostingProviderType.GITHUB -> loadGitHubPromptRaw(repo.host, repo.projectKey, repo.repoSlug, path, config.branch, config.token) + else -> return@mapNotNull null + } + parseGlobalSkillMeta(path, content) + } + } + if (strict) return result.getOrThrow() + return result.getOrDefault(emptyList()) + } + + private fun parseGlobalSkillMeta(path: String, content: String): GlobalPromptMeta? { + if (!isSkillMarkdownPath(path)) return null + val text = content.trim() + if (text.isBlank()) return null + val nameMatch = Regex("(?im)^name\\s*:\\s*(.+)$").find(text)?.groupValues?.get(1)?.trim() + val timeMatch = Regex("(?im)^time\\s*:\\s*(.+)$").find(text)?.groupValues?.get(1)?.trim() + val updatedAtMatch = Regex("(?im)^updatedAt\\s*:\\s*(.+)$").find(text)?.groupValues?.get(1)?.trim() + val name = nameMatch?.takeIf { it.isNotBlank() } ?: File(path).nameWithoutExtension + val time = parseInstantOrNow(timeMatch ?: updatedAtMatch) + return GlobalPromptMeta( + category = "skill", + name = name, + updatedAt = time, + template = text, + sourcePriority = 2 + ) + } + + private fun loadBitbucketSkillFilePaths( + project: Project, host: String, projectKey: String, repoSlug: String, + branch: String, config: BitbucketPromptRepoConfig + ): List { + val encodedBranch = URLEncoder.encode("refs/heads/$branch", StandardCharsets.UTF_8) + val url = "https://$host/rest/api/1.0/projects/$projectKey/repos/$repoSlug/files?at=$encodedBranch&limit=1000" + val body = bitbucketGet(project, url, config) + val values = json.parseToJsonElement(body).jsonObject["values"]?.jsonArray ?: return emptyList() + return values.mapNotNull { it.jsonPrimitive.contentOrNull } + .filter { isSkillMarkdownPath(it) } + } + + private fun loadBitbucketSkillFilePathsFromRawBaseUrl( + project: Project, rawBaseUrl: String, branch: String, config: BitbucketPromptRepoConfig + ): List { + val normalizedBase = rawBaseUrl.trimEnd('/') + val filesBase = normalizedBase.replace(Regex("/raw/?$"), "/files") + val encodedBranch = URLEncoder.encode("refs/heads/$branch", StandardCharsets.UTF_8) + val url = "$filesBase?at=$encodedBranch&limit=1000" + val body = bitbucketGet(project, url, config) + val values = json.parseToJsonElement(body).jsonObject["values"]?.jsonArray ?: return emptyList() + return values.mapNotNull { it.jsonPrimitive.contentOrNull } + .filter { isSkillMarkdownPath(it) } + } + + private fun loadGitHubSkillFilePaths( + host: String, owner: String, repo: String, branch: String, token: String + ): List { + val encodedBranch = URLEncoder.encode(branch, StandardCharsets.UTF_8) + val url = "https://api.$host/repos/$owner/$repo/git/trees/$encodedBranch?recursive=1" + val body = githubGet(url, token) + val tree = json.parseToJsonElement(body).jsonObject["tree"]?.jsonArray ?: return emptyList() + return tree.mapNotNull { node -> + val obj = node.jsonObject + val type = obj["type"]?.jsonPrimitive?.contentOrNull + val path = obj["path"]?.jsonPrimitive?.contentOrNull + if (type == "blob" && path != null && isSkillMarkdownPath(path)) path else null + } + } + + private fun writeGlobalSkillCache(project: Project, root: Map, remoteEntries: List) { + val mutableRoot = root.toMutableLinkedMap() + val skills = (mutableRoot["skills"] as? Map<*, *>).toMutableLinkedMap() + val suppressedGlobalSkills = parseSuppressedGlobalItems(skills) + val existingItems = (skills["items"] as? Map<*, *>).toMutableLinkedMap() + val itemsWithoutOldGlobal = existingItems + .filterKeys { !it.toString().startsWith("global/") } + .toMutableMap() + + remoteEntries + .filterNot { it.cacheKey in suppressedGlobalSkills } + .sortedWith(compareByDescending { it.updatedAt }.thenBy { it.name }) + .forEach { meta -> + itemsWithoutOldGlobal[meta.cacheKey] = ensurePromptUpdateMetadata(meta) + } + + skills["selected"] = skills["selected"]?.toString()?.takeIf { it.isNotBlank() } ?: "default" + skills["items"] = itemsWithoutOldGlobal + skills["suppressed"] = suppressedGlobalSkills + mutableRoot["skills"] = skills + writeRootMap(project, mutableRoot) + } + + private fun readCachedGlobalSkillKeys(project: Project): Set { + val root = readRootMap(project) + val skills = root["skills"] as? Map<*, *> ?: return emptySet() + val items = skills["items"] as? Map<*, *> ?: return emptySet() + return items.keys.mapNotNull { it?.toString() }.filter { it.startsWith("global/") }.toSet() + } } diff --git a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SonarCubeToolWindowPanel.kt b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SonarCubeToolWindowPanel.kt index 9820a34..c4b4349 100644 --- a/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SonarCubeToolWindowPanel.kt +++ b/plugin-idea/src/main/kotlin/org/openprojectx/ai/plugin/SonarCubeToolWindowPanel.kt @@ -47,16 +47,20 @@ object SonarCubeToolWindowPanel { border = BorderFactory.createEmptyBorder(10, 10, 10, 10) text = "Click Refresh to load configured SonarQube results." } + var selectedIssue: SonarCubeIssue? = null val issueList = JList(issueListModel).apply { selectionMode = ListSelectionModel.SINGLE_SELECTION background = Color(0x11, 0x1C, 0x2F) foreground = fgColor fixedCellHeight = 54 cellRenderer = SonarCubeIssueRenderer() + addListSelectionListener { + if (!it.valueIsAdjusting) selectedIssue = selectedValue + } addMouseListener(object : MouseAdapter() { override fun mouseClicked(e: MouseEvent) { if (e.clickCount >= 2) { - selectedValue?.let { openIssue(project, it) } + selectedValue?.let { issue -> openIssue(project, issue) } } } }) @@ -106,7 +110,12 @@ object SonarCubeToolWindowPanel { refreshButton.addActionListener { load() } openButton.addActionListener { - issueList.selectedValue?.let { openIssue(project, it) } + val issue = selectedIssue + if (issue == null) { + Notifications.warn(project, "Sonar Cube", "Please select an issue first.") + } else { + openIssue(project, issue) + } } val root = JPanel(BorderLayout(8, 8)).apply { @@ -158,7 +167,7 @@ private class SonarCubeToolWindowClient(private val config: SonarQubeConfig) { private val authHeader = SonarQubeAuth.authorizationHeader(config) suspend fun load(): SonarCubeResult { - val client = HttpClients.shared(timeoutSeconds = 60) + val client = HttpClients.shared(disableTlsVerification = true, timeoutSeconds = 60) try { val baseUrl = config.serverUrl.trimEnd('/') val projectKey = encoded(config.projectKey)