Skip to content

Commit 6a12fa4

Browse files
edufalcaoclaude
andcommitted
Prepare v1.2.0 release
Add Tree Explorer tab for hierarchical navigation of deeply nested config diffs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 60cfb89 commit 6a12fa4

9 files changed

Lines changed: 397 additions & 9 deletions

File tree

app/components/results/ResultsPanel.vue

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import type { DiffResult } from '~/types/diff';
2+
import type { DiffResult, ResultsTab } from '~/types/diff';
33
import type { RiskAnnotation } from '~/types/risk';
44
55
const props = defineProps<{
@@ -9,13 +9,13 @@ const props = defineProps<{
99
shareUrl: string | null,
1010
isSharing: boolean,
1111
copied: boolean,
12-
activeTab: 'semantic' | 'raw' | 'summary'
12+
activeTab: ResultsTab
1313
}>();
1414
1515
const emit = defineEmits<{
1616
'share': [],
1717
'copyUrl': [],
18-
'update:activeTab': [value: 'semantic' | 'raw' | 'summary']
18+
'update:activeTab': [value: ResultsTab]
1919
}>();
2020
2121
const showExportMenu = ref(false);
@@ -91,7 +91,7 @@ function formatDisplayValue(value: unknown): string {
9191
<!-- Tab controls + share + export -->
9292
<div class="flex items-center gap-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-1">
9393
<button
94-
v-for="tab in (['semantic', 'raw', 'summary'] as const)"
94+
v-for="tab in (['semantic', 'raw', 'tree', 'summary'] as const)"
9595
:key="tab"
9696
:class="[
9797
'rounded-md px-3 py-1.5 font-[var(--font-mono)] text-xs font-medium transition-colors',
@@ -101,7 +101,7 @@ function formatDisplayValue(value: unknown): string {
101101
]"
102102
@click="emit('update:activeTab', tab)"
103103
>
104-
{{ tab === 'semantic' ? 'Semantic' : tab === 'raw' ? 'Raw Diff' : 'Summary' }}
104+
{{ tab === 'semantic' ? 'Semantic' : tab === 'raw' ? 'Raw Diff' : tab === 'tree' ? 'Tree' : 'Summary' }}
105105
</button>
106106

107107
<div class="ml-auto flex items-center gap-2 px-1">
@@ -195,6 +195,14 @@ function formatDisplayValue(value: unknown): string {
195195
<pre class="p-4 font-[var(--font-mono)] text-sm text-[var(--color-text)]">{{ result.rawDiff }}</pre>
196196
</div>
197197

198+
<!-- Tree Explorer -->
199+
<ResultsTreeExplorer
200+
v-else-if="activeTab === 'tree'"
201+
:changes="result.changes"
202+
:risks="risks"
203+
:mask-secrets="maskSecrets"
204+
/>
205+
198206
<!-- Smart Summary -->
199207
<div
200208
v-else
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
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)]">&rarr;</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>

app/components/ui/KeyboardHelp.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ const shortcuts = [
1414
{ key: 's', description: 'Toggle secret masking' },
1515
{ key: '1', description: 'Semantic diff tab' },
1616
{ key: '2', description: 'Raw diff tab' },
17-
{ key: '3', description: 'Summary tab' },
17+
{ key: '3', description: 'Tree tab' },
18+
{ key: '4', description: 'Summary tab' },
1819
{ key: '?', description: 'Show/hide this help' },
1920
{ key: 'Esc', description: 'Close this overlay' }
2021
];

app/composables/useKeyboard.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ export interface KeyboardCallbacks {
33
onToggleFormatFilter?: () => void,
44
onToggleRiskyFilter?: () => void,
55
onToggleSecretMask?: () => void,
6-
onSwitchTab?: (tab: 'semantic' | 'raw' | 'summary') => void
6+
onSwitchTab?: (tab: import('~/types/diff').ResultsTab) => void
77
}
88

99
const showHelp = ref(false);
@@ -74,6 +74,10 @@ export function useKeyboard(callbacks: KeyboardCallbacks = {}) {
7474
callbacks.onSwitchTab?.('raw');
7575
break;
7676
case '3':
77+
e.preventDefault();
78+
callbacks.onSwitchTab?.('tree');
79+
break;
80+
case '4':
7781
e.preventDefault();
7882
callbacks.onSwitchTab?.('summary');
7983
break;

app/pages/index.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ function handleShare() {
3737
}
3838
}
3939
40-
const activeTab = ref<'semantic' | 'raw' | 'summary'>('semantic');
40+
const activeTab = ref<import('~/types/diff').ResultsTab>('semantic');
4141
4242
const { showHelp } = useKeyboard({
4343
onCompare: () => compare(),

app/pages/s/[id].vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const format = ref<ConfigFormat>('env');
1818
const { shareUrl, isSharing, copied, copyUrl } = useShare();
1919
shareUrl.value = window.location.href;
2020
21-
const activeTab = ref<'semantic' | 'raw' | 'summary'>('semantic');
21+
const activeTab = ref<import('~/types/diff').ResultsTab>('semantic');
2222
2323
try {
2424
const data = await $fetch<{

app/types/diff.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import type { ConfigFormat } from './config';
2+
import type { RiskSeverity } from './risk';
23

34
export type ChangeType = 'added' | 'removed' | 'changed' | 'unchanged';
45

6+
export type ResultsTab = 'semantic' | 'raw' | 'summary' | 'tree';
7+
58
export interface DiffChange {
69
path: string,
710
type: ChangeType,
@@ -23,3 +26,20 @@ export interface DiffStats {
2326
unchanged: number,
2427
total: number
2528
}
29+
30+
export interface TreeNodeStats {
31+
added: number,
32+
removed: number,
33+
changed: number,
34+
unchanged: number
35+
}
36+
37+
export interface TreeNode {
38+
name: string,
39+
path: string,
40+
depth: number,
41+
change: DiffChange | null,
42+
children: TreeNode[],
43+
stats: TreeNodeStats,
44+
maxRiskSeverity: RiskSeverity | null
45+
}

app/utils/diff/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { semanticDiff } from './semantic';
66
export { rawDiff } from './raw';
77
export { semanticDiff } from './semantic';
88
export { generateSummary } from './summary';
9+
export { buildTree } from './tree';
910

1011
export function compareConfigs(left: ConfigTree, right: ConfigTree): DiffResult {
1112
const changes = semanticDiff(left, right);

0 commit comments

Comments
 (0)