Skip to content

Commit dc7af28

Browse files
authored
Merge pull request #8 from dmitrish/develop
develop
2 parents 98a20d3 + d825b7a commit dc7af28

2 files changed

Lines changed: 429 additions & 117 deletions

File tree

src/main/kotlin/com/coroutines/androidresourceusagetracker/ResourceUsageLineMarkerProvider.kt

Lines changed: 233 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -3,77 +3,261 @@ package com.coroutines.androidresourceusagetracker
33
import com.intellij.codeInsight.daemon.LineMarkerInfo
44
import com.intellij.codeInsight.daemon.LineMarkerProvider
55
import com.intellij.openapi.editor.markup.GutterIconRenderer
6+
import com.intellij.openapi.fileEditor.OpenFileDescriptor
7+
import com.intellij.openapi.project.Project
8+
import com.intellij.openapi.vfs.LocalFileSystem
69
import com.intellij.psi.PsiElement
710
import com.intellij.psi.xml.XmlTag
11+
import com.intellij.ui.Gray
12+
import com.intellij.ui.JBColor
13+
import java.awt.*
14+
import java.awt.event.MouseAdapter
15+
import java.awt.event.MouseEvent
16+
import java.awt.geom.RoundRectangle2D
17+
import javax.swing.*
18+
import javax.swing.border.EmptyBorder
819

920
class ResourceUsageLineMarkerProvider : LineMarkerProvider {
1021

22+
/*
1123
override fun getLineMarkerInfo(element: PsiElement): LineMarkerInfo<*>? {
12-
return null
24+
if (element !is XmlTag) return null
25+
if (element.name !in listOf("string", "color", "dimen", "style", "drawable", "integer", "bool", "array", "string-array", "integer-array", "plurals", "id")) {
26+
return null
27+
}
28+
29+
val resourceName = element.getAttributeValue("name") ?: return null
30+
val count = UsageCounter.countUsages(element)
31+
32+
return LineMarkerInfo(
33+
element,
34+
element.textRange,
35+
createUsageIcon(count),
36+
{ "$count usage${if (count != 1) "s" else ""}" },
37+
{ e, elt ->
38+
if (count > 0) {
39+
showUsagesPopup(e, elt as XmlTag, elt.project)
40+
}
41+
},
42+
GutterIconRenderer.Alignment.RIGHT,
43+
{ "$count usage${if (count != 1) "s" else ""}" }
44+
)
1345
}
1446
15-
override fun collectSlowLineMarkers(
16-
elements: List<PsiElement>,
17-
result: MutableCollection<in LineMarkerInfo<*>>
18-
) {
19-
for (element in elements) {
20-
if (element !is XmlTag) continue
21-
if (!isAndroidResourceTag(element)) continue
22-
23-
val resourceName = element.getAttributeValue("name") ?: continue
24-
val usageCount = UsageCounter.countUsages(element)
25-
26-
val icon = ResourceUsageIconGenerator.createIcon(usageCount)
27-
val anchorElement = element.firstChild ?: continue
28-
29-
val lineMarker = LineMarkerInfo(
30-
anchorElement,
31-
element.textRange,
32-
icon,
33-
{ createTooltip(resourceName, usageCount, element) },
34-
null,
35-
GutterIconRenderer.Alignment.RIGHT
36-
)
47+
*/
48+
49+
override fun getLineMarkerInfo(element: PsiElement): LineMarkerInfo<*>? {
50+
// Only process the tag name identifier (leaf element), not the whole tag
51+
if (element !is com.intellij.psi.xml.XmlToken) return null
52+
if (element.tokenType != com.intellij.psi.xml.XmlTokenType.XML_NAME) return null
3753

38-
result.add(lineMarker)
54+
// Make sure this is the opening tag name, not closing tag or attribute name
55+
// The previous sibling should be XML_START_TAG_START ("<")
56+
val prevSibling = element.prevSibling
57+
if (prevSibling !is com.intellij.psi.xml.XmlToken ||
58+
prevSibling.tokenType != com.intellij.psi.xml.XmlTokenType.XML_START_TAG_START) {
59+
return null
3960
}
61+
62+
val parent = element.parent
63+
if (parent !is XmlTag) return null
64+
65+
// Check if this is a resource tag we care about
66+
if (parent.name !in listOf("string", "color", "dimen", "style", "drawable", "integer", "bool", "array", "string-array", "integer-array", "plurals", "id")) {
67+
return null
68+
}
69+
70+
// Only process if this tag has a "name" attribute (it's a resource definition)
71+
val resourceName = parent.getAttributeValue("name") ?: return null
72+
73+
// Make sure we're in a values XML file (check parent directory name)
74+
val parentDirName = element.containingFile.virtualFile?.parent?.name ?: ""
75+
if (!parentDirName.contains("values")) {
76+
return null
77+
}
78+
79+
val count = UsageCounter.countUsages(parent)
80+
81+
return LineMarkerInfo(
82+
element, // Register on the leaf element (XML_NAME token)
83+
element.textRange,
84+
createUsageIcon(count),
85+
{ "$count usage${if (count != 1) "s" else ""}" },
86+
{ e, elt ->
87+
if (count > 0) {
88+
// Navigate up to the XmlTag for processing
89+
val tag = elt.parent as? XmlTag ?: return@LineMarkerInfo
90+
showUsagesPopup(e, tag, tag.project)
91+
}
92+
},
93+
GutterIconRenderer.Alignment.RIGHT,
94+
{ "$count usage${if (count != 1) "s" else ""}" }
95+
)
4096
}
97+
private fun createUsageIcon(count: Int): Icon {
98+
return object : Icon {
99+
override fun getIconWidth() = 21
100+
override fun getIconHeight() = 21
101+
102+
override fun paintIcon(c: Component?, g: Graphics?, x: Int, y: Int) {
103+
val g2d = g as Graphics2D
104+
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
105+
106+
val color = when {
107+
count == 0 -> JBColor(Color(200, 200, 200), Gray._100)
108+
count < 5 -> JBColor(Color(100, 180, 255), Color(80, 140, 200))
109+
else -> JBColor(Color(100, 200, 100), Color(80, 160, 80))
110+
}
111+
112+
g2d.color = color
113+
g2d.fill(RoundRectangle2D.Double(x.toDouble(), y.toDouble(), 20.0, 20.0, 6.0, 6.0))
41114

42-
private fun createTooltip(resourceName: String, usageCount: Int, element: XmlTag): String {
43-
if (usageCount == 0) {
44-
return "$resourceName: Not used"
115+
g2d.color = JBColor.WHITE
116+
g2d.font = Font("SansSerif", Font.BOLD, 11)
117+
val text = if (count > 99) "99+" else count.toString()
118+
val fm = g2d.fontMetrics
119+
val textWidth = fm.stringWidth(text)
120+
val textX = x + (20 - textWidth) / 2
121+
val textY = y + ((20 - fm.height) / 2) + fm.ascent
122+
g2d.drawString(text, textX, textY)
123+
}
45124
}
125+
}
46126

127+
private fun showUsagesPopup(event: MouseEvent, element: XmlTag, project: Project) {
47128
val usages = UsageCounter.getUsages(element)
48-
val displayCount = if (usageCount > 99) "99+" else usageCount.toString()
129+
if (usages.isEmpty()) return
49130

50-
val tooltip = buildString {
51-
append("<html>")
52-
append("<b>$resourceName</b>: $displayCount usage${if (usageCount != 1) "s" else ""}<br><br>")
131+
val popup = JWindow()
132+
popup.type = Window.Type.POPUP
53133

54-
// Show up to 5 usages in tooltip
55-
usages.take(5).forEach { usage ->
56-
append("<b>${usage.filePath}:${usage.lineNumber}</b><br>")
57-
append("<code>${usage.codeSnippet}</code><br><br>")
58-
}
134+
val panel = JPanel(BorderLayout()).apply {
135+
border = BorderFactory.createCompoundBorder(
136+
BorderFactory.createLineBorder(JBColor.border(), 1),
137+
EmptyBorder(8, 8, 8, 8)
138+
)
139+
background = JBColor.background()
140+
}
59141

60-
if (usages.size > 5) {
61-
append("<i>...and ${usages.size - 5} more</i>")
62-
}
142+
val titleLabel = JLabel("${usages.size} usage${if (usages.size != 1) "s" else ""}").apply {
143+
font = font.deriveFont(Font.BOLD, 13f)
144+
border = EmptyBorder(0, 0, 8, 0)
145+
}
63146

64-
append("</html>")
147+
panel.add(titleLabel, BorderLayout.NORTH)
148+
149+
val usagesList = createUsagesList(usages, project)
150+
val scrollPane = JScrollPane(usagesList).apply {
151+
preferredSize = Dimension(600, minOf(300, usages.size * 60 + 20))
152+
border = null
65153
}
66154

67-
return tooltip
155+
panel.add(scrollPane, BorderLayout.CENTER)
156+
157+
popup.contentPane = panel
158+
popup.pack()
159+
160+
val locationOnScreen = event.component.locationOnScreen
161+
popup.setLocation(locationOnScreen.x + event.x + 10, locationOnScreen.y + event.y)
162+
163+
popup.isVisible = true
164+
// popup.isFocusableWindowState = true
165+
popup.requestFocus()
166+
167+
popup.addWindowFocusListener(object : java.awt.event.WindowFocusListener {
168+
override fun windowGainedFocus(e: java.awt.event.WindowEvent?) {}
169+
override fun windowLostFocus(e: java.awt.event.WindowEvent?) {
170+
popup.dispose()
171+
}
172+
})
68173
}
69174

70-
private fun isAndroidResourceTag(tag: XmlTag): Boolean {
71-
val validTags = setOf(
72-
"string", "color", "dimen", "style", "drawable",
73-
"integer", "bool", "array", "string-array", "integer-array",
74-
"plurals", "attr", "declare-styleable", "item", "id"
75-
)
76-
return validTags.contains(tag.name) && tag.getAttribute("name") != null
175+
private fun createUsagesList(usages: List<ResourceUsage>, project: Project): JList<ResourceUsage> {
176+
val listModel = DefaultListModel<ResourceUsage>()
177+
usages.forEach { listModel.addElement(it) }
178+
179+
return JList(listModel).apply {
180+
cellRenderer = UsageCellRenderer()
181+
selectionMode = ListSelectionModel.SINGLE_SELECTION
182+
183+
addMouseListener(object : MouseAdapter() {
184+
override fun mouseClicked(e: MouseEvent) {
185+
if (e.clickCount == 2) {
186+
val usage = selectedValue ?: return
187+
188+
// Use the full absolute path directly for navigation
189+
val virtualFile = LocalFileSystem.getInstance().findFileByPath(usage.filePath)
190+
191+
if (virtualFile != null) {
192+
val descriptor = OpenFileDescriptor(project, virtualFile, usage.lineNumber - 1, 0)
193+
descriptor.navigate(true)
194+
}
195+
}
196+
}
197+
})
198+
}
77199
}
78-
}
79200

201+
private class UsageCellRenderer : DefaultListCellRenderer() {
202+
override fun getListCellRendererComponent(
203+
list: JList<*>?,
204+
value: Any?,
205+
index: Int,
206+
isSelected: Boolean,
207+
cellHasFocus: Boolean
208+
): Component {
209+
val usage = value as ResourceUsage
210+
211+
val panel = JPanel(BorderLayout()).apply {
212+
border = EmptyBorder(4, 8, 4, 8)
213+
background = if (isSelected) JBColor.background() else JBColor.background()
214+
}
215+
216+
// Get display-friendly path
217+
val displayPath = getDisplayPath(usage.filePath)
218+
219+
val fileLabel = JLabel("<html><b style='color: #589df6;'>$displayPath:${usage.lineNumber}</b></html>").apply {
220+
font = Font("Monospaced", Font.PLAIN, 12)
221+
}
222+
223+
val codeLabel = JLabel("<html><span style='color: #808080; font-family: monospace;'>${escapeHtml(usage.codeSnippet)}</span></html>").apply {
224+
font = Font("Monospaced", Font.PLAIN, 11)
225+
}
226+
227+
val labelsPanel = JPanel().apply {
228+
layout = BoxLayout(this, BoxLayout.Y_AXIS)
229+
add(fileLabel)
230+
add(Box.createVerticalStrut(2))
231+
add(codeLabel)
232+
background = if (isSelected) JBColor.background() else JBColor.background()
233+
}
234+
235+
panel.add(labelsPanel, BorderLayout.CENTER)
236+
237+
if (isSelected) {
238+
panel.background = JBColor(Color(220, 230, 240), Color(60, 70, 80))
239+
labelsPanel.background = panel.background
240+
}
241+
242+
return panel
243+
}
244+
245+
private fun getDisplayPath(path: String): String {
246+
// Try to get path relative to common source roots for display
247+
return when {
248+
path.contains("/src/main/") -> path.substringAfter("/src/main/")
249+
path.contains("/src/test/") -> path.substringAfter("/src/test/")
250+
path.contains("/src/androidTest/") -> path.substringAfter("/src/androidTest/")
251+
else -> path.substringAfterLast("/")
252+
}
253+
}
254+
255+
private fun escapeHtml(text: String): String {
256+
return text
257+
.replace("&", "&amp;")
258+
.replace("<", "&lt;")
259+
.replace(">", "&gt;")
260+
.replace("\"", "&quot;")
261+
}
262+
}
263+
}

0 commit comments

Comments
 (0)