|
| 1 | +<script setup lang="ts"> |
| 2 | +import type { DiffChange, TreeNode } from '~/types/diff'; |
| 3 | +import type { RiskAnnotation } from '~/types/risk'; |
| 4 | +import { buildTree } from '~/utils/diff/tree'; |
| 5 | +import { maskValue, isLikelySecret } from '~/utils/risk'; |
| 6 | +
|
| 7 | +const props = defineProps<{ |
| 8 | + changes: DiffChange[], |
| 9 | + risks: RiskAnnotation[], |
| 10 | + maskSecrets: boolean |
| 11 | +}>(); |
| 12 | +
|
| 13 | +const tree = computed(() => buildTree(props.changes, props.risks)); |
| 14 | +
|
| 15 | +const collapsed = ref(new Set<string>()); |
| 16 | +
|
| 17 | +// Default: top-level expanded, deeper levels collapsed |
| 18 | +watch(tree, (nodes) => { |
| 19 | + const toCollapse = new Set<string>(); |
| 20 | + function walk(node: TreeNode) { |
| 21 | + if (node.children.length > 0 && node.depth >= 1) { |
| 22 | + toCollapse.add(node.path); |
| 23 | + } |
| 24 | + for (const child of node.children) { |
| 25 | + walk(child); |
| 26 | + } |
| 27 | + } |
| 28 | + for (const node of nodes) { |
| 29 | + walk(node); |
| 30 | + } |
| 31 | + collapsed.value = toCollapse; |
| 32 | +}, { immediate: true }); |
| 33 | +
|
| 34 | +function toggle(path: string) { |
| 35 | + const next = new Set(collapsed.value); |
| 36 | + if (next.has(path)) { |
| 37 | + next.delete(path); |
| 38 | + } else { |
| 39 | + next.add(path); |
| 40 | + } |
| 41 | + collapsed.value = next; |
| 42 | +} |
| 43 | +
|
| 44 | +function expandAll() { |
| 45 | + collapsed.value = new Set(); |
| 46 | +} |
| 47 | +
|
| 48 | +function collapseAll() { |
| 49 | + const all = new Set<string>(); |
| 50 | + function walk(node: TreeNode) { |
| 51 | + if (node.children.length > 0) { |
| 52 | + all.add(node.path); |
| 53 | + } |
| 54 | + for (const child of node.children) { |
| 55 | + walk(child); |
| 56 | + } |
| 57 | + } |
| 58 | + for (const node of tree.value) { |
| 59 | + walk(node); |
| 60 | + } |
| 61 | + collapsed.value = all; |
| 62 | +} |
| 63 | +
|
| 64 | +interface FlatRow { |
| 65 | + node: TreeNode, |
| 66 | + isBranch: boolean, |
| 67 | + isExpanded: boolean |
| 68 | +} |
| 69 | +
|
| 70 | +const visibleRows = computed<FlatRow[]>(() => { |
| 71 | + const rows: FlatRow[] = []; |
| 72 | + function walk(nodes: TreeNode[]) { |
| 73 | + for (const node of nodes) { |
| 74 | + const isBranch = node.children.length > 0; |
| 75 | + const isExpanded = isBranch && !collapsed.value.has(node.path); |
| 76 | + rows.push({ node, isBranch, isExpanded }); |
| 77 | + if (isExpanded) { |
| 78 | + walk(node.children); |
| 79 | + } |
| 80 | + } |
| 81 | + } |
| 82 | + walk(tree.value); |
| 83 | + return rows; |
| 84 | +}); |
| 85 | +
|
| 86 | +function formatValue(value: unknown): string { |
| 87 | + if (value === undefined || value === null) return ''; |
| 88 | + if (typeof value === 'string') return value; |
| 89 | + return JSON.stringify(value); |
| 90 | +} |
| 91 | +
|
| 92 | +function shouldMask(path: string, value: unknown): boolean { |
| 93 | + return isLikelySecret(path, String(value ?? '')); |
| 94 | +} |
| 95 | +
|
| 96 | +function displayValue(path: string, value: unknown): string { |
| 97 | + const formatted = formatValue(value); |
| 98 | + if (props.maskSecrets && shouldMask(path, value)) { |
| 99 | + return maskValue(formatted); |
| 100 | + } |
| 101 | + return formatted; |
| 102 | +} |
| 103 | +
|
| 104 | +const riskDotColor: Record<string, string> = { |
| 105 | + high: 'bg-[var(--color-risk-high)]', |
| 106 | + review: 'bg-[var(--color-risk-review)]', |
| 107 | + info: 'bg-[var(--color-risk-info)]' |
| 108 | +}; |
| 109 | +</script> |
| 110 | + |
| 111 | +<template> |
| 112 | + <div class="overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)]"> |
| 113 | + <!-- Toolbar --> |
| 114 | + <div class="flex items-center gap-2 border-b border-[var(--color-border)] px-4 py-2"> |
| 115 | + <button |
| 116 | + class="rounded-md px-2 py-1 font-[var(--font-mono)] text-xs text-[var(--color-muted)] transition-colors hover:bg-[var(--color-elevated)] hover:text-[var(--color-text)]" |
| 117 | + @click="expandAll" |
| 118 | + > |
| 119 | + Expand all |
| 120 | + </button> |
| 121 | + <button |
| 122 | + class="rounded-md px-2 py-1 font-[var(--font-mono)] text-xs text-[var(--color-muted)] transition-colors hover:bg-[var(--color-elevated)] hover:text-[var(--color-text)]" |
| 123 | + @click="collapseAll" |
| 124 | + > |
| 125 | + Collapse all |
| 126 | + </button> |
| 127 | + </div> |
| 128 | + |
| 129 | + <!-- Tree rows --> |
| 130 | + <div class="divide-y divide-[var(--color-border)]"> |
| 131 | + <div |
| 132 | + v-for="row in visibleRows" |
| 133 | + :key="row.node.path" |
| 134 | + :class="[ |
| 135 | + 'flex items-center gap-2 py-1.5 pr-4 font-[var(--font-mono)] text-sm', |
| 136 | + !row.isBranch && row.node.change ? { |
| 137 | + 'bg-[var(--color-added-bg)]': row.node.change.type === 'added', |
| 138 | + 'bg-[var(--color-removed-bg)]': row.node.change.type === 'removed', |
| 139 | + 'bg-[var(--color-changed-bg)]': row.node.change.type === 'changed' |
| 140 | + } : '' |
| 141 | + ]" |
| 142 | + :style="{ paddingLeft: `${(row.node.depth * 20) + 16}px` }" |
| 143 | + > |
| 144 | + <!-- Branch row --> |
| 145 | + <template v-if="row.isBranch"> |
| 146 | + <button |
| 147 | + class="flex h-5 w-5 shrink-0 items-center justify-center rounded text-xs text-[var(--color-muted)] transition-colors hover:bg-[var(--color-elevated)] hover:text-[var(--color-text)]" |
| 148 | + @click="toggle(row.node.path)" |
| 149 | + > |
| 150 | + {{ row.isExpanded ? '▼' : '▶' }} |
| 151 | + </button> |
| 152 | + |
| 153 | + <span class="font-medium text-[var(--color-text)]">{{ row.node.name }}</span> |
| 154 | + |
| 155 | + <!-- Compact badges --> |
| 156 | + <span |
| 157 | + v-if="row.node.stats.added > 0" |
| 158 | + class="rounded-full bg-[var(--color-accent)]/15 px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent)]" |
| 159 | + > |
| 160 | + +{{ row.node.stats.added }} |
| 161 | + </span> |
| 162 | + <span |
| 163 | + v-if="row.node.stats.changed > 0" |
| 164 | + class="rounded-full bg-[var(--color-risk-review)]/15 px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-risk-review)]" |
| 165 | + > |
| 166 | + ~{{ row.node.stats.changed }} |
| 167 | + </span> |
| 168 | + <span |
| 169 | + v-if="row.node.stats.removed > 0" |
| 170 | + class="rounded-full bg-[var(--color-accent-2)]/15 px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-accent-2)]" |
| 171 | + > |
| 172 | + -{{ row.node.stats.removed }} |
| 173 | + </span> |
| 174 | + |
| 175 | + <!-- Risk dot --> |
| 176 | + <span |
| 177 | + v-if="row.node.maxRiskSeverity" |
| 178 | + :class="['ml-auto h-2 w-2 shrink-0 rounded-full', riskDotColor[row.node.maxRiskSeverity]]" |
| 179 | + /> |
| 180 | + </template> |
| 181 | + |
| 182 | + <!-- Leaf row --> |
| 183 | + <template v-else-if="row.node.change"> |
| 184 | + <span |
| 185 | + :class="[ |
| 186 | + 'flex h-5 w-5 shrink-0 items-center justify-center rounded text-xs font-bold', |
| 187 | + { |
| 188 | + 'bg-[var(--color-accent)]/20 text-[var(--color-accent)]': row.node.change.type === 'added', |
| 189 | + 'bg-[var(--color-accent-2)]/20 text-[var(--color-accent-2)]': row.node.change.type === 'removed', |
| 190 | + 'bg-[var(--color-risk-review)]/20 text-[var(--color-risk-review)]': row.node.change.type === 'changed', |
| 191 | + 'text-[var(--color-muted)]': row.node.change.type === 'unchanged' |
| 192 | + } |
| 193 | + ]" |
| 194 | + > |
| 195 | + {{ row.node.change.type === 'added' ? '+' : row.node.change.type === 'removed' ? '-' : row.node.change.type === 'changed' ? '~' : ' ' }} |
| 196 | + </span> |
| 197 | + |
| 198 | + <span class="shrink-0 text-[var(--color-text)]">{{ row.node.name }}</span> |
| 199 | + |
| 200 | + <!-- Values --> |
| 201 | + <span |
| 202 | + v-if="row.node.change.type === 'changed'" |
| 203 | + class="ml-auto text-right" |
| 204 | + > |
| 205 | + <span class="text-[var(--color-accent-2)] line-through opacity-60">{{ displayValue(row.node.change.path, row.node.change.oldValue) }}</span> |
| 206 | + <span class="mx-1 text-[var(--color-muted)]">→</span> |
| 207 | + <span class="text-[var(--color-accent)]">{{ displayValue(row.node.change.path, row.node.change.newValue) }}</span> |
| 208 | + </span> |
| 209 | + <span |
| 210 | + v-else-if="row.node.change.type === 'added'" |
| 211 | + class="ml-auto text-right text-[var(--color-accent)]" |
| 212 | + > |
| 213 | + {{ displayValue(row.node.change.path, row.node.change.newValue) }} |
| 214 | + </span> |
| 215 | + <span |
| 216 | + v-else-if="row.node.change.type === 'removed'" |
| 217 | + class="ml-auto text-right text-[var(--color-accent-2)]" |
| 218 | + > |
| 219 | + {{ displayValue(row.node.change.path, row.node.change.oldValue) }} |
| 220 | + </span> |
| 221 | + <span |
| 222 | + v-else |
| 223 | + class="ml-auto text-right text-[var(--color-muted)]" |
| 224 | + > |
| 225 | + {{ displayValue(row.node.change.path, row.node.change.newValue ?? row.node.change.oldValue) }} |
| 226 | + </span> |
| 227 | + |
| 228 | + <!-- Risk dot for leaf --> |
| 229 | + <span |
| 230 | + v-if="row.node.maxRiskSeverity" |
| 231 | + :class="['h-2 w-2 shrink-0 rounded-full', riskDotColor[row.node.maxRiskSeverity]]" |
| 232 | + /> |
| 233 | + </template> |
| 234 | + </div> |
| 235 | + |
| 236 | + <div |
| 237 | + v-if="visibleRows.length === 0" |
| 238 | + class="px-4 py-8 text-center text-sm text-[var(--color-muted)]" |
| 239 | + > |
| 240 | + No differences found. |
| 241 | + </div> |
| 242 | + </div> |
| 243 | + </div> |
| 244 | +</template> |
0 commit comments