Skip to content

Commit aef9ca7

Browse files
committed
Add a path with copying to hover on nodes
1 parent df661fe commit aef9ca7

4 files changed

Lines changed: 120 additions & 25 deletions

File tree

package-lock.json

Lines changed: 0 additions & 17 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/TreeContainer.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ defineProps<{
1717
:node="node"
1818
:primitive-results="primitiveResults"
1919
:level="0"
20+
:path="node.path"
2021
/>
2122
</template>
22-
<TreeNode v-else-if="tree" :node="tree" :primitive-results="primitiveResults" :level="0" />
23+
<TreeNode v-else-if="tree" :node="tree" :primitive-results="primitiveResults" :level="0" :path="tree.path" />
2324
</div>
2425
</template>
2526

src/components/TreeNode.vue

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
<script setup lang="ts">
2-
import { computed } from 'vue'
2+
import { computed, ref } from 'vue'
33
import type { TreeNode } from '../utils/astTreeBuilder'
44
import { usePrimitiveMatching } from '../composables/usePrimitiveMatching'
55
66
const props = defineProps<{
77
node: TreeNode
88
primitiveResults: Array<string | number | boolean>
99
level?: number
10+
path?: string
1011
}>()
1112
1213
const emit = defineEmits<{
@@ -34,11 +35,53 @@ const toggleExpand = () => {
3435
}
3536
3637
const indent = computed(() => (props.level || 0) * 20)
38+
39+
const showTooltip = ref(false)
40+
const copied = ref(false)
41+
const tooltipRef = ref<HTMLElement | null>(null)
42+
const nodeContentRef = ref<HTMLElement | null>(null)
43+
44+
const tooltipLeft = computed(() => {
45+
if (!nodeContentRef.value || !tooltipRef.value) return 0
46+
const rect = nodeContentRef.value.getBoundingClientRect()
47+
const tooltipRect = tooltipRef.value.getBoundingClientRect()
48+
return rect.left + rect.width / 2 - tooltipRect.width / 2
49+
})
50+
51+
const tooltipTop = computed(() => {
52+
if (!nodeContentRef.value) return 0
53+
const rect = nodeContentRef.value.getBoundingClientRect()
54+
return rect.top - 10
55+
})
56+
57+
const showTooltipHandler = () => {
58+
showTooltip.value = true
59+
}
60+
61+
const hideTooltipHandler = () => {
62+
showTooltip.value = false
63+
copied.value = false
64+
}
65+
66+
const copyPath = async () => {
67+
if (props.path) {
68+
try {
69+
await navigator.clipboard.writeText(props.path)
70+
copied.value = true
71+
setTimeout(() => {
72+
copied.value = false
73+
}, 1500)
74+
} catch {
75+
// Clipboard API not available
76+
}
77+
}
78+
}
3779
</script>
3880

3981
<template>
4082
<div class="tree-node">
4183
<div
84+
ref="nodeContentRef"
4285
class="node-content"
4386
:class="{
4487
matched: node.isMatched,
@@ -49,6 +92,9 @@ const indent = computed(() => (props.level || 0) * 20)
4992
}"
5093
:style="{ paddingLeft: `${indent}px` }"
5194
@click="toggleExpand"
95+
@mouseenter="showTooltipHandler"
96+
@mouseleave="hideTooltipHandler"
97+
v-if="path"
5298
>
5399
<span class="expand-icon" v-if="node.children.length > 0">
54100
{{ node.isExpanded ? '▼' : '▶' }}
@@ -67,8 +113,25 @@ const indent = computed(() => (props.level || 0) * 20)
67113
:node="child"
68114
:primitive-results="primitiveResults"
69115
:level="(level || 0) + 1"
116+
:path="child.path"
70117
/>
71118
</div>
119+
120+
<teleport to="body">
121+
<div
122+
ref="tooltipRef"
123+
v-if="path && showTooltip"
124+
class="path-tooltip"
125+
:style="{ left: tooltipLeft + 'px', top: tooltipTop + 'px' }"
126+
@mouseenter="showTooltipHandler"
127+
@mouseleave="hideTooltipHandler"
128+
>
129+
<div class="tooltip-path">{{ path }}</div>
130+
<button class="tooltip-copy" @click="copyPath">
131+
{{ copied ? 'Copied!' : 'Copy' }}
132+
</button>
133+
</div>
134+
</teleport>
72135
</div>
73136
</template>
74137

@@ -178,4 +241,45 @@ const indent = computed(() => (props.level || 0) * 20)
178241
.children {
179242
margin-left: 0;
180243
}
244+
245+
.path-tooltip {
246+
position: fixed;
247+
z-index: 10000;
248+
background: #1e1e1e;
249+
color: #d4d4d4;
250+
border: 1px solid #454545;
251+
border-radius: 6px;
252+
padding: 8px 12px;
253+
display: flex;
254+
align-items: center;
255+
gap: 8px;
256+
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
257+
font-size: 12px;
258+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
259+
pointer-events: auto;
260+
white-space: nowrap;
261+
}
262+
263+
.tooltip-path {
264+
color: #79c0ff;
265+
overflow: hidden;
266+
text-overflow: ellipsis;
267+
max-width: 400px;
268+
}
269+
270+
.tooltip-copy {
271+
background: #303030;
272+
color: #c9d1d9;
273+
border: 1px solid #555;
274+
border-radius: 4px;
275+
padding: 2px 8px;
276+
font-size: 11px;
277+
cursor: pointer;
278+
flex-shrink: 0;
279+
transition: background-color 0.15s;
280+
}
281+
282+
.tooltip-copy:hover {
283+
background: #444;
284+
}
181285
</style>

src/utils/astTreeBuilder.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,13 @@ export interface TreeNode {
8787
astNode: ASTNode
8888
primitiveValue?: string | number | boolean
8989
attributeName?: string
90+
path: string
9091
}
9192

9293
/**
9394
* Format a node label with relevant information based on node type
9495
*/
95-
function formatNodeLabel(node: ASTNode, attributeName?: string): string {
96+
function formatNodeLabel(node: ASTNode): string {
9697
let label = node.type
9798

9899
// Add useful info based on type
@@ -141,6 +142,7 @@ export function buildTree(
141142
matchedNodes: Set<ASTNode>,
142143
primitiveResults: Array<string | number | boolean> = [],
143144
attributeName?: string,
145+
parentPath: string = '',
144146
): TreeNode {
145147
const children: TreeNode[] = []
146148

@@ -150,16 +152,20 @@ export function buildTree(
150152
// eslint-disable-next-line @typescript-eslint/no-explicit-any
151153
const anyNode = node as any
152154

155+
// Compute the path segment for this node
156+
const segment = attributeName ? `${attributeName}:${node.type}` : node.type
157+
const currentPath = parentPath ? `${parentPath}/${segment}` : `/${segment}`
158+
153159
if (
154160
node.type === 'TemplateLiteral' &&
155161
Array.isArray(anyNode.quasis) &&
156162
Array.isArray(anyNode.expressions)
157163
) {
158164
// Interleave quasis and expressions by source position: quasi[0], expr[0], quasi[1], expr[1], ..., quasi[n]
159165
for (let i = 0; i < anyNode.quasis.length; i++) {
160-
children.push(buildTree(anyNode.quasis[i], matchedNodes, primitiveResults, 'quasis'))
166+
children.push(buildTree(anyNode.quasis[i], matchedNodes, primitiveResults, 'quasis', currentPath))
161167
if (i < anyNode.expressions.length) {
162-
children.push(buildTree(anyNode.expressions[i], matchedNodes, primitiveResults, 'expressions'))
168+
children.push(buildTree(anyNode.expressions[i], matchedNodes, primitiveResults, 'expressions', currentPath))
163169
}
164170
}
165171
} else {
@@ -169,11 +175,11 @@ export function buildTree(
169175
if (Array.isArray(child)) {
170176
for (const item of child) {
171177
if (item && typeof item === 'object' && 'type' in item) {
172-
children.push(buildTree(item, matchedNodes, primitiveResults, key))
178+
children.push(buildTree(item, matchedNodes, primitiveResults, key, currentPath))
173179
}
174180
}
175181
} else if (child && typeof child === 'object' && 'type' in child) {
176-
children.push(buildTree(child, matchedNodes, primitiveResults, key))
182+
children.push(buildTree(child, matchedNodes, primitiveResults, key, currentPath))
177183
}
178184
}
179185
}
@@ -182,13 +188,14 @@ export function buildTree(
182188
type: node.type,
183189
start: node.start,
184190
end: node.end,
185-
label: formatNodeLabel(node, attributeName),
191+
label: formatNodeLabel(node),
186192
children,
187193
isMatched: matchedNodes.has(node),
188194
isAncestorOfMatch: false,
189195
isExpanded: false,
190196
astNode: node,
191197
attributeName,
198+
path: currentPath,
192199
}
193200

194201
return treeNode

0 commit comments

Comments
 (0)