@myko/ui-svelte 4.3.0 → 4.4.0-canary.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/lib/components/EntityDiffBadge.svelte +18 -0
- package/src/lib/components/EntityDiffFields.svelte +245 -0
- package/src/lib/components/EntityDiffNode.svelte +96 -0
- package/src/lib/components/EntityDiffTree.svelte +608 -0
- package/src/lib/utils/entity-apply.ts +47 -0
- package/src/lib/utils/entity-diff.ts +105 -0
- package/src/lib/utils/entity-tree.ts +130 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"dependencies": {
|
|
3
|
-
"@myko/core": "^4.
|
|
3
|
+
"@myko/core": "^4.4.0-canary.2"
|
|
4
4
|
},
|
|
5
5
|
"devDependencies": {
|
|
6
6
|
"@sveltejs/adapter-auto": "^3.0.0",
|
|
@@ -60,5 +60,5 @@
|
|
|
60
60
|
"**/*.css"
|
|
61
61
|
],
|
|
62
62
|
"type": "module",
|
|
63
|
-
"version": "4.
|
|
63
|
+
"version": "4.4.0-canary.2"
|
|
64
64
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { DiffStatus } from '../utils/entity-diff.js';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
status: DiffStatus;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
let { status }: Props = $props();
|
|
9
|
+
|
|
10
|
+
const config: Record<DiffStatus, { label: string; class: string }> = {
|
|
11
|
+
added: { label: 'Added', class: 'badge-success' },
|
|
12
|
+
removed: { label: 'Removed', class: 'badge-error' },
|
|
13
|
+
modified: { label: 'Modified', class: 'badge-warning' },
|
|
14
|
+
unchanged: { label: 'Unchanged', class: 'badge-ghost' },
|
|
15
|
+
};
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<span class="badge badge-sm {config[status].class}">{config[status].label}</span>
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { FieldDiff } from '../utils/entity-diff.js';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
fields: FieldDiff[];
|
|
6
|
+
/** Full current entity data (for side-by-side view). */
|
|
7
|
+
currentData?: Record<string, unknown>;
|
|
8
|
+
/** Full incoming entity data (for side-by-side view). */
|
|
9
|
+
incomingData?: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let { fields, currentData, incomingData }: Props = $props();
|
|
13
|
+
|
|
14
|
+
const hasSideBySide = $derived(!!currentData && !!incomingData);
|
|
15
|
+
|
|
16
|
+
function formatValue(v: unknown): string {
|
|
17
|
+
if (v === undefined || v === null) return '';
|
|
18
|
+
if (typeof v === 'object') return JSON.stringify(v, null, 2);
|
|
19
|
+
return String(v);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isObject(v: unknown): v is Record<string, unknown> {
|
|
23
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface DiffLine {
|
|
27
|
+
key: string;
|
|
28
|
+
status: 'added' | 'removed' | 'changed' | 'unchanged';
|
|
29
|
+
oldVal?: string;
|
|
30
|
+
newVal?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function computeJsonDiff(oldVal: unknown, newVal: unknown): DiffLine[] | null {
|
|
34
|
+
if (!isObject(oldVal) || !isObject(newVal)) return null;
|
|
35
|
+
const allKeys = new Set([...Object.keys(oldVal), ...Object.keys(newVal)]);
|
|
36
|
+
const lines: DiffLine[] = [];
|
|
37
|
+
for (const key of allKeys) {
|
|
38
|
+
const o = oldVal[key];
|
|
39
|
+
const n = newVal[key];
|
|
40
|
+
if (!(key in newVal)) {
|
|
41
|
+
lines.push({ key, status: 'removed', oldVal: JSON.stringify(o) });
|
|
42
|
+
} else if (!(key in oldVal)) {
|
|
43
|
+
lines.push({ key, status: 'added', newVal: JSON.stringify(n) });
|
|
44
|
+
} else if (JSON.stringify(o) !== JSON.stringify(n)) {
|
|
45
|
+
lines.push({ key, status: 'changed', oldVal: JSON.stringify(o), newVal: JSON.stringify(n) });
|
|
46
|
+
} else {
|
|
47
|
+
lines.push({ key, status: 'unchanged', oldVal: JSON.stringify(o), newVal: JSON.stringify(n) });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return lines;
|
|
51
|
+
}
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
{#if hasSideBySide}
|
|
55
|
+
<div class="diff-fields">
|
|
56
|
+
{#each fields as field}
|
|
57
|
+
{@const jsonDiff = computeJsonDiff(field.oldValue, field.newValue)}
|
|
58
|
+
<div class="field-section">
|
|
59
|
+
<span class="field-name">{field.field}</span>
|
|
60
|
+
{#if jsonDiff}
|
|
61
|
+
<div class="diff-table json-layout">
|
|
62
|
+
<div class="diff-header json-layout">
|
|
63
|
+
<span class="col-label col-key"></span>
|
|
64
|
+
<span class="col-label">existing</span>
|
|
65
|
+
<span class="col-label">pending</span>
|
|
66
|
+
<span class="col-label">result</span>
|
|
67
|
+
</div>
|
|
68
|
+
{#each jsonDiff as line}
|
|
69
|
+
<div
|
|
70
|
+
class="diff-row json-layout"
|
|
71
|
+
class:row-added={line.status === 'added'}
|
|
72
|
+
class:row-removed={line.status === 'removed'}
|
|
73
|
+
class:row-changed={line.status === 'changed'}
|
|
74
|
+
>
|
|
75
|
+
<span class="cell cell-key">{line.key}</span>
|
|
76
|
+
<span class="cell cell-existing">{line.status === 'added' ? '' : line.oldVal}</span>
|
|
77
|
+
<span class="cell cell-pending">{line.status === 'removed' ? '' : line.newVal}</span>
|
|
78
|
+
<span class="cell cell-result">{line.status === 'removed' ? '' : (line.newVal ?? line.oldVal)}</span>
|
|
79
|
+
</div>
|
|
80
|
+
{/each}
|
|
81
|
+
</div>
|
|
82
|
+
{:else}
|
|
83
|
+
<div class="diff-table scalar-layout">
|
|
84
|
+
<div class="diff-header scalar-layout">
|
|
85
|
+
<span class="col-label">existing</span>
|
|
86
|
+
<span class="col-label">pending</span>
|
|
87
|
+
<span class="col-label">result</span>
|
|
88
|
+
</div>
|
|
89
|
+
<div
|
|
90
|
+
class="diff-row scalar-layout"
|
|
91
|
+
class:row-changed={field.oldValue !== undefined && field.newValue !== undefined}
|
|
92
|
+
class:row-added={field.oldValue === undefined}
|
|
93
|
+
class:row-removed={field.newValue === undefined}
|
|
94
|
+
>
|
|
95
|
+
<span class="cell cell-existing">{formatValue(field.oldValue)}</span>
|
|
96
|
+
<span class="cell cell-pending">{formatValue(field.newValue)}</span>
|
|
97
|
+
<span class="cell cell-result">{formatValue(field.newValue)}</span>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
{/if}
|
|
101
|
+
</div>
|
|
102
|
+
{/each}
|
|
103
|
+
</div>
|
|
104
|
+
{:else}
|
|
105
|
+
<div class="pl-6 text-sm space-y-1">
|
|
106
|
+
{#each fields as field}
|
|
107
|
+
<div class="font-mono">
|
|
108
|
+
<span class="text-base-content/60">{field.field}:</span>
|
|
109
|
+
{#if field.oldValue === undefined}
|
|
110
|
+
<span class="text-success">+ {formatValue(field.newValue)}</span>
|
|
111
|
+
{:else if field.newValue === undefined}
|
|
112
|
+
<span class="text-error">- {formatValue(field.oldValue)}</span>
|
|
113
|
+
{:else}
|
|
114
|
+
<span class="text-error">{formatValue(field.oldValue)}</span>
|
|
115
|
+
<span class="text-base-content/40">→</span>
|
|
116
|
+
<span class="text-success">{formatValue(field.newValue)}</span>
|
|
117
|
+
{/if}
|
|
118
|
+
</div>
|
|
119
|
+
{/each}
|
|
120
|
+
</div>
|
|
121
|
+
{/if}
|
|
122
|
+
|
|
123
|
+
<style>
|
|
124
|
+
.diff-fields {
|
|
125
|
+
margin-left: 1.5rem;
|
|
126
|
+
font-size: var(--rs-fs-xs, 0.75rem);
|
|
127
|
+
font-family: monospace;
|
|
128
|
+
overflow-x: auto;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.field-section {
|
|
132
|
+
padding: 0.5rem 0;
|
|
133
|
+
border-bottom: 1px solid oklch(var(--bc) / 0.05);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.field-section:last-child {
|
|
137
|
+
border-bottom: none;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.field-name {
|
|
141
|
+
display: block;
|
|
142
|
+
opacity: 0.5;
|
|
143
|
+
font-size: 0.65rem;
|
|
144
|
+
text-transform: uppercase;
|
|
145
|
+
letter-spacing: 0.03em;
|
|
146
|
+
margin-bottom: 0.375rem;
|
|
147
|
+
font-family: sans-serif;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* --- Diff table --- */
|
|
151
|
+
.diff-table {
|
|
152
|
+
min-width: max-content;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.diff-header {
|
|
156
|
+
display: grid;
|
|
157
|
+
gap: 1.5rem;
|
|
158
|
+
padding: 0 0.5rem 0.25rem;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.col-label {
|
|
162
|
+
font-size: 0.6rem;
|
|
163
|
+
opacity: 0.35;
|
|
164
|
+
text-transform: uppercase;
|
|
165
|
+
letter-spacing: 0.05em;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.col-label.col-key {
|
|
169
|
+
opacity: 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.diff-row {
|
|
173
|
+
display: grid;
|
|
174
|
+
gap: 1.5rem;
|
|
175
|
+
padding: 0.25rem 0.5rem;
|
|
176
|
+
border-radius: 0.25rem;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/* Grid layouts */
|
|
180
|
+
.json-layout {
|
|
181
|
+
grid-template-columns: minmax(100px, max-content) 1fr 1fr 1fr;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.scalar-layout {
|
|
185
|
+
grid-template-columns: 1fr 1fr 1fr;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* Row backgrounds */
|
|
189
|
+
.diff-row.row-added {
|
|
190
|
+
background: oklch(var(--su) / 0.08);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.diff-row.row-removed {
|
|
194
|
+
background: oklch(var(--er) / 0.08);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.diff-row.row-changed {
|
|
198
|
+
background: oklch(var(--wa) / 0.08);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/* Cells */
|
|
202
|
+
.cell {
|
|
203
|
+
white-space: nowrap;
|
|
204
|
+
overflow: hidden;
|
|
205
|
+
text-overflow: ellipsis;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.cell-key {
|
|
209
|
+
opacity: 0.6;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.cell-existing {
|
|
213
|
+
color: oklch(var(--bc) / 0.5);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.cell-pending {
|
|
217
|
+
color: oklch(var(--bc) / 0.8);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.cell-result {
|
|
221
|
+
color: oklch(var(--bc) / 0.6);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/* Row-specific highlight colors */
|
|
225
|
+
.row-removed .cell-existing {
|
|
226
|
+
color: oklch(var(--er));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.row-added .cell-pending,
|
|
230
|
+
.row-added .cell-result {
|
|
231
|
+
color: oklch(var(--su));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.row-changed .cell-existing {
|
|
235
|
+
color: oklch(var(--er));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.row-changed .cell-pending {
|
|
239
|
+
color: oklch(var(--wa));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.row-changed .cell-result {
|
|
243
|
+
color: oklch(var(--wa));
|
|
244
|
+
}
|
|
245
|
+
</style>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { EntityDiff } from '../utils/entity-diff.js';
|
|
3
|
+
import EntityDiffBadge from './EntityDiffBadge.svelte';
|
|
4
|
+
import EntityDiffFields from './EntityDiffFields.svelte';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
node: EntityDiff;
|
|
8
|
+
showUnchanged?: boolean;
|
|
9
|
+
/** Set of "Type:id" keys excluded from import. */
|
|
10
|
+
excluded?: Set<string>;
|
|
11
|
+
/** Callback to toggle an entity's excluded state. */
|
|
12
|
+
onToggleExclude?: (key: string) => void;
|
|
13
|
+
/** Whether this is the root node (always visible). */
|
|
14
|
+
isRoot?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let { node, showUnchanged = false, excluded, onToggleExclude, isRoot = false }: Props = $props();
|
|
18
|
+
let expanded = $state(node.status !== 'unchanged' || isRoot);
|
|
19
|
+
|
|
20
|
+
const key = $derived(`${node.type}:${node.id}`);
|
|
21
|
+
const isExcluded = $derived(excluded?.has(key) ?? false);
|
|
22
|
+
const canExclude = $derived(
|
|
23
|
+
!isRoot && (node.status === 'modified' || node.status === 'added' || node.status === 'removed'),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const toggleExclude = (e: Event) => {
|
|
27
|
+
e.stopPropagation();
|
|
28
|
+
onToggleExclude?.(key);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const hasChangedDescendant = $derived.by((): boolean => {
|
|
32
|
+
const check = (n: EntityDiff): boolean => {
|
|
33
|
+
if (n.status !== 'unchanged') return true;
|
|
34
|
+
return n.children.some(check);
|
|
35
|
+
};
|
|
36
|
+
return node.children.some(check);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const visible = $derived(
|
|
40
|
+
isRoot || showUnchanged || node.status !== 'unchanged' || hasChangedDescendant,
|
|
41
|
+
);
|
|
42
|
+
const hasDetails = $derived(
|
|
43
|
+
(node.fields && node.fields.length > 0) || node.children.length > 0,
|
|
44
|
+
);
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
{#if visible}
|
|
48
|
+
<div class="border-l-2 border-base-300 pl-3 py-1" class:opacity-40={isExcluded}>
|
|
49
|
+
<div class="flex items-center gap-1">
|
|
50
|
+
<button
|
|
51
|
+
class="flex items-center gap-2 flex-1 text-left hover:bg-base-200 rounded px-1"
|
|
52
|
+
onclick={() => (expanded = !expanded)}
|
|
53
|
+
disabled={!hasDetails}
|
|
54
|
+
>
|
|
55
|
+
{#if hasDetails}
|
|
56
|
+
<span class="text-xs">{expanded ? '▼' : '▶'}</span>
|
|
57
|
+
{:else}
|
|
58
|
+
<span class="text-xs w-3"></span>
|
|
59
|
+
{/if}
|
|
60
|
+
<span class="text-xs text-base-content/50">{node.type}</span>
|
|
61
|
+
<span class="font-medium">{node.name ?? node.id}</span>
|
|
62
|
+
<EntityDiffBadge status={node.status} />
|
|
63
|
+
{#if isExcluded}
|
|
64
|
+
<span class="badge badge-sm badge-ghost">keeping original</span>
|
|
65
|
+
{/if}
|
|
66
|
+
</button>
|
|
67
|
+
{#if canExclude && onToggleExclude}
|
|
68
|
+
<button
|
|
69
|
+
class="btn btn-xs btn-ghost text-xs opacity-60 hover:opacity-100"
|
|
70
|
+
onclick={toggleExclude}
|
|
71
|
+
title={isExcluded ? 'Accept incoming version' : 'Keep original version'}
|
|
72
|
+
>
|
|
73
|
+
{isExcluded ? 'overwrite' : 'keep original'}
|
|
74
|
+
</button>
|
|
75
|
+
{/if}
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
{#if expanded && !isExcluded}
|
|
79
|
+
{#if node.fields && node.fields.length > 0}
|
|
80
|
+
<EntityDiffFields
|
|
81
|
+
fields={node.fields}
|
|
82
|
+
currentData={node.currentData}
|
|
83
|
+
incomingData={node.incomingData}
|
|
84
|
+
/>
|
|
85
|
+
{/if}
|
|
86
|
+
|
|
87
|
+
{#if node.children.length > 0}
|
|
88
|
+
<div class="pl-2">
|
|
89
|
+
{#each node.children as child (child.type + ':' + child.id)}
|
|
90
|
+
<svelte:self node={child} {showUnchanged} {excluded} {onToggleExclude} />
|
|
91
|
+
{/each}
|
|
92
|
+
</div>
|
|
93
|
+
{/if}
|
|
94
|
+
{/if}
|
|
95
|
+
</div>
|
|
96
|
+
{/if}
|
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { ExportedEntity, EntityDiff, DiffStatus } from '../utils/entity-diff.js';
|
|
3
|
+
import { diffEntityLists } from '../utils/entity-diff.js';
|
|
4
|
+
import type { ParentFkFields } from '../utils/entity-tree.js';
|
|
5
|
+
import { buildDiffTree } from '../utils/entity-tree.js';
|
|
6
|
+
import EntityDiffBadge from './EntityDiffBadge.svelte';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
/** Root entity type (e.g., "Project") */
|
|
10
|
+
rootType: string;
|
|
11
|
+
/** Root entity ID */
|
|
12
|
+
rootId: string;
|
|
13
|
+
/** Entities currently on the server */
|
|
14
|
+
current: ExportedEntity[];
|
|
15
|
+
/** Entities from the incoming source (file or snapshot) */
|
|
16
|
+
incoming: ExportedEntity[];
|
|
17
|
+
/** Maps child entity type to parent FK field names */
|
|
18
|
+
parentFkFields: ParentFkFields;
|
|
19
|
+
/** Set of "Type:id" keys the user chose to exclude from import */
|
|
20
|
+
excluded?: Set<string>;
|
|
21
|
+
/** Whether to show unchanged entities */
|
|
22
|
+
showUnchanged?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let { rootType, rootId, current, incoming, parentFkFields, excluded = $bindable(new Set()), showUnchanged = false }: Props = $props();
|
|
26
|
+
|
|
27
|
+
const diffs = $derived(diffEntityLists(current, incoming));
|
|
28
|
+
const tree = $derived(buildDiffTree(rootType, rootId, diffs, parentFkFields));
|
|
29
|
+
|
|
30
|
+
const toggleExclude = (key: string) => {
|
|
31
|
+
const next = new Set(excluded);
|
|
32
|
+
if (next.has(key)) {
|
|
33
|
+
next.delete(key);
|
|
34
|
+
} else {
|
|
35
|
+
next.add(key);
|
|
36
|
+
}
|
|
37
|
+
excluded = next;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Action labels per status. First = apply action, second = skip action. */
|
|
41
|
+
function actionLabels(status: DiffStatus): [string, string] {
|
|
42
|
+
switch (status) {
|
|
43
|
+
case 'removed': return ['delete', 'keep'];
|
|
44
|
+
case 'added': return ['add', 'skip'];
|
|
45
|
+
case 'modified': return ['update', 'keep'];
|
|
46
|
+
default: return ['apply', 'skip'];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
const stats = $derived.by(() => {
|
|
52
|
+
let toDelete = 0, toUpdate = 0, toAdd = 0, toSkip = 0, unchanged = 0;
|
|
53
|
+
for (const [key, d] of diffs) {
|
|
54
|
+
if (d.status === 'unchanged') { unchanged++; continue; }
|
|
55
|
+
if (excluded.has(key)) { toSkip++; continue; }
|
|
56
|
+
if (d.status === 'removed') toDelete++;
|
|
57
|
+
else if (d.status === 'modified') toUpdate++;
|
|
58
|
+
else if (d.status === 'added') toAdd++;
|
|
59
|
+
}
|
|
60
|
+
return { toDelete, toUpdate, toAdd, toSkip, unchanged };
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// --- Batch operations ---
|
|
64
|
+
|
|
65
|
+
/** Keys grouped by change type (excludes unchanged). */
|
|
66
|
+
const keysByStatus = $derived.by(() => {
|
|
67
|
+
const groups: Record<string, string[]> = { added: [], removed: [], modified: [] };
|
|
68
|
+
for (const [key, d] of diffs) {
|
|
69
|
+
if (d.status !== 'unchanged') groups[d.status]?.push(key);
|
|
70
|
+
}
|
|
71
|
+
return groups;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/** Keys grouped by entity type (excludes unchanged). */
|
|
75
|
+
const keysByEntityType = $derived.by(() => {
|
|
76
|
+
const groups: Record<string, string[]> = {};
|
|
77
|
+
for (const [key, d] of diffs) {
|
|
78
|
+
if (d.status === 'unchanged') continue;
|
|
79
|
+
const [type] = key.split(':');
|
|
80
|
+
(groups[type] ??= []).push(key);
|
|
81
|
+
}
|
|
82
|
+
return groups;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
function batchApply(keys: string[]) {
|
|
86
|
+
const next = new Set(excluded);
|
|
87
|
+
for (const k of keys) next.delete(k);
|
|
88
|
+
excluded = next;
|
|
89
|
+
saveExpanded(expandedSet);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function batchSkip(keys: string[]) {
|
|
93
|
+
const next = new Set(excluded);
|
|
94
|
+
for (const k of keys) next.add(k);
|
|
95
|
+
excluded = next;
|
|
96
|
+
saveExpanded(expandedSet);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- Flatten tree into grid rows ---
|
|
100
|
+
|
|
101
|
+
type FlatRow =
|
|
102
|
+
| { kind: 'entity'; node: EntityDiff; depth: number; isRoot: boolean }
|
|
103
|
+
| { kind: 'field'; fieldName: string; existing: string; pending: string; result: string; status: 'added' | 'removed' | 'changed'; depth: number; parentKey: string };
|
|
104
|
+
|
|
105
|
+
const STORAGE_KEY = `rship:diff-expanded:${rootType}:${rootId}`;
|
|
106
|
+
|
|
107
|
+
let expandedSet = $state(loadExpanded());
|
|
108
|
+
|
|
109
|
+
function loadExpanded(): Set<string> {
|
|
110
|
+
try {
|
|
111
|
+
const stored = sessionStorage.getItem(STORAGE_KEY);
|
|
112
|
+
if (stored) return new Set(JSON.parse(stored));
|
|
113
|
+
} catch { /* ignore */ }
|
|
114
|
+
// Default: only root expanded
|
|
115
|
+
return new Set([`${rootType}:${rootId}`]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function saveExpanded(set: Set<string>) {
|
|
119
|
+
try {
|
|
120
|
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify([...set]));
|
|
121
|
+
} catch { /* ignore */ }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function hasChanges(node: EntityDiff): boolean {
|
|
125
|
+
if (node.status !== 'unchanged') return true;
|
|
126
|
+
return node.children.some(hasChanges);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function toggleExpand(key: string) {
|
|
130
|
+
const next = new Set(expandedSet);
|
|
131
|
+
if (next.has(key)) next.delete(key);
|
|
132
|
+
else next.add(key);
|
|
133
|
+
expandedSet = next;
|
|
134
|
+
saveExpanded(next);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function formatVal(v: unknown): string {
|
|
138
|
+
if (v === undefined || v === null) return '';
|
|
139
|
+
if (typeof v === 'object') return JSON.stringify(v, null, 2);
|
|
140
|
+
return String(v);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const rows = $derived.by((): FlatRow[] => {
|
|
144
|
+
const result: FlatRow[] = [];
|
|
145
|
+
|
|
146
|
+
const walk = (node: EntityDiff, depth: number, isRoot: boolean) => {
|
|
147
|
+
const key = `${node.type}:${node.id}`;
|
|
148
|
+
const visible = isRoot || showUnchanged || node.status !== 'unchanged' || hasChanges(node);
|
|
149
|
+
if (!visible) return;
|
|
150
|
+
|
|
151
|
+
result.push({ kind: 'entity', node, depth, isRoot });
|
|
152
|
+
|
|
153
|
+
const isExpanded = expandedSet.has(key);
|
|
154
|
+
const isExcluded = excluded.has(key);
|
|
155
|
+
|
|
156
|
+
if (isExpanded && !isExcluded) {
|
|
157
|
+
// Emit field rows for modified entities
|
|
158
|
+
if (node.fields && node.fields.length > 0) {
|
|
159
|
+
for (const f of node.fields) {
|
|
160
|
+
const oldStr = formatVal(f.oldValue);
|
|
161
|
+
const newStr = formatVal(f.newValue);
|
|
162
|
+
const status: 'added' | 'removed' | 'changed' =
|
|
163
|
+
f.oldValue === undefined ? 'added' :
|
|
164
|
+
f.newValue === undefined ? 'removed' : 'changed';
|
|
165
|
+
result.push({
|
|
166
|
+
kind: 'field',
|
|
167
|
+
fieldName: f.field,
|
|
168
|
+
existing: oldStr,
|
|
169
|
+
pending: newStr,
|
|
170
|
+
result: status === 'removed' ? '' : newStr,
|
|
171
|
+
status,
|
|
172
|
+
depth: depth + 1,
|
|
173
|
+
parentKey: key,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Emit fields for added/removed entities (all fields as a block)
|
|
179
|
+
if (!node.fields || node.fields.length === 0) {
|
|
180
|
+
const data = node.status === 'removed' ? node.currentData : node.status === 'added' ? node.incomingData : null;
|
|
181
|
+
if (data) {
|
|
182
|
+
for (const [k, v] of Object.entries(data)) {
|
|
183
|
+
if (k === 'hash') continue;
|
|
184
|
+
const valStr = formatVal(v);
|
|
185
|
+
result.push({
|
|
186
|
+
kind: 'field',
|
|
187
|
+
fieldName: k,
|
|
188
|
+
existing: node.status === 'removed' ? valStr : '',
|
|
189
|
+
pending: node.status === 'added' ? valStr : '',
|
|
190
|
+
result: node.status === 'added' ? valStr : '',
|
|
191
|
+
status: node.status === 'removed' ? 'removed' : 'added',
|
|
192
|
+
depth: depth + 1,
|
|
193
|
+
parentKey: key,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Recurse into children
|
|
200
|
+
for (const child of node.children) {
|
|
201
|
+
walk(child, depth + 1, false);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
walk(tree, 0, true);
|
|
207
|
+
return result;
|
|
208
|
+
});
|
|
209
|
+
</script>
|
|
210
|
+
|
|
211
|
+
<div class="diff-grid-wrapper">
|
|
212
|
+
<!-- Summary + batch controls -->
|
|
213
|
+
<div class="summary-panel">
|
|
214
|
+
<div class="summary-heading">Apply will:</div>
|
|
215
|
+
<div class="summary-rows">
|
|
216
|
+
{#each Object.entries(keysByStatus) as [status, keys]}
|
|
217
|
+
{#if keys.length > 0}
|
|
218
|
+
{@const activeKeys = keys.filter(k => !excluded.has(k))}
|
|
219
|
+
{@const skippedKeys = keys.filter(k => excluded.has(k))}
|
|
220
|
+
{@const [applyLabel, skipLabel] = actionLabels(status as DiffStatus)}
|
|
221
|
+
{@const allExcluded = activeKeys.length === 0}
|
|
222
|
+
{@const noneExcluded = skippedKeys.length === 0}
|
|
223
|
+
{@const statusColor = status === 'removed' ? 'text-error' : status === 'added' ? 'text-success' : 'text-warning'}
|
|
224
|
+
<div class="summary-row">
|
|
225
|
+
<span class="summary-action {statusColor}">{applyLabel} {activeKeys.length}</span>
|
|
226
|
+
{#if skippedKeys.length > 0}
|
|
227
|
+
<span class="summary-skip">{skipLabel} {skippedKeys.length}</span>
|
|
228
|
+
{/if}
|
|
229
|
+
<span class="summary-total">of {keys.length}</span>
|
|
230
|
+
<div class="join summary-btns">
|
|
231
|
+
<button class="join-item btn btn-xs" class:btn-active={noneExcluded} onclick={() => batchApply(keys)}>{applyLabel} all</button>
|
|
232
|
+
<button class="join-item btn btn-xs" class:btn-active={allExcluded} onclick={() => batchSkip(keys)}>{skipLabel} all</button>
|
|
233
|
+
</div>
|
|
234
|
+
<div class="dropdown dropdown-bottom dropdown-end">
|
|
235
|
+
<div tabindex="0" role="button" class="btn btn-xs btn-ghost opacity-50">by type</div>
|
|
236
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
237
|
+
<div tabindex="0" class="dropdown-content menu bg-base-200 rounded-lg shadow-lg z-10 p-2 w-64">
|
|
238
|
+
{#each Object.entries(keysByEntityType).sort(([a], [b]) => a.localeCompare(b)) as [entityType, etKeys]}
|
|
239
|
+
{@const matching = etKeys.filter(k => keys.includes(k))}
|
|
240
|
+
{#if matching.length > 0}
|
|
241
|
+
{@const etAllExcluded = matching.every(k => excluded.has(k))}
|
|
242
|
+
{@const etNoneExcluded = matching.every(k => !excluded.has(k))}
|
|
243
|
+
<div class="batch-type-row">
|
|
244
|
+
<span class="batch-type-name">{entityType} <span class="opacity-40">({matching.length})</span></span>
|
|
245
|
+
<div class="join">
|
|
246
|
+
<button class="join-item btn btn-xs" class:btn-active={etNoneExcluded} onclick={() => batchApply(matching)}>apply</button>
|
|
247
|
+
<button class="join-item btn btn-xs" class:btn-active={etAllExcluded} onclick={() => batchSkip(matching)}>skip</button>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
{/if}
|
|
251
|
+
{/each}
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
{/if}
|
|
256
|
+
{/each}
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
|
|
260
|
+
<!-- Column headers -->
|
|
261
|
+
<div class="grid-header">
|
|
262
|
+
<span class="col-head col-tree"></span>
|
|
263
|
+
<span class="col-head">Existing</span>
|
|
264
|
+
<span class="col-head">Pending</span>
|
|
265
|
+
<span class="col-head">Result</span>
|
|
266
|
+
<span class="col-head col-action"></span>
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
<!-- Grid rows -->
|
|
270
|
+
<div class="grid-body">
|
|
271
|
+
{#each rows as row, i (row.kind === 'entity' ? `${row.node.type}:${row.node.id}` : `${row.parentKey}:${row.fieldName}`)}
|
|
272
|
+
{#if row.kind === 'entity'}
|
|
273
|
+
{@const key = `${row.node.type}:${row.node.id}`}
|
|
274
|
+
{@const isExpanded = expandedSet.has(key)}
|
|
275
|
+
{@const isExcluded = excluded.has(key)}
|
|
276
|
+
{@const canExclude = !row.isRoot && (row.node.status === 'modified' || row.node.status === 'added' || row.node.status === 'removed')}
|
|
277
|
+
{@const hasDetails = (row.node.fields && row.node.fields.length > 0) || row.node.children.length > 0 || !!row.node.currentData || !!row.node.incomingData}
|
|
278
|
+
|
|
279
|
+
{@const willDelete = !isExcluded && row.node.status === 'removed'}
|
|
280
|
+
{@const willAdd = !isExcluded && row.node.status === 'added'}
|
|
281
|
+
{@const willUpdate = !isExcluded && row.node.status === 'modified'}
|
|
282
|
+
{@const willSkip = isExcluded && row.node.status !== 'unchanged'}
|
|
283
|
+
<div
|
|
284
|
+
class="grid-row entity-row"
|
|
285
|
+
class:outcome-delete={willDelete}
|
|
286
|
+
class:outcome-add={willAdd}
|
|
287
|
+
class:outcome-update={willUpdate}
|
|
288
|
+
class:outcome-skip={willSkip}
|
|
289
|
+
>
|
|
290
|
+
<!-- Tree column -->
|
|
291
|
+
<div class="cell cell-tree" style:padding-left="{row.depth * 1.25}rem">
|
|
292
|
+
<button
|
|
293
|
+
class="tree-toggle"
|
|
294
|
+
onclick={() => toggleExpand(key)}
|
|
295
|
+
disabled={!hasDetails}
|
|
296
|
+
>
|
|
297
|
+
{#if hasDetails}
|
|
298
|
+
<span class="chevron">{isExpanded ? '▼' : '▶'}</span>
|
|
299
|
+
{:else}
|
|
300
|
+
<span class="chevron-spacer"></span>
|
|
301
|
+
{/if}
|
|
302
|
+
<span class="entity-type">{row.node.type}</span>
|
|
303
|
+
<span class="entity-name">{row.node.name ?? row.node.id}</span>
|
|
304
|
+
{#if row.node.status === 'unchanged'}
|
|
305
|
+
<EntityDiffBadge status={row.node.status} />
|
|
306
|
+
{/if}
|
|
307
|
+
</button>
|
|
308
|
+
</div>
|
|
309
|
+
|
|
310
|
+
<!-- Value columns — empty for entity header rows, fields show below -->
|
|
311
|
+
<div class="cell cell-val"></div>
|
|
312
|
+
<div class="cell cell-val"></div>
|
|
313
|
+
<div class="cell cell-val"></div>
|
|
314
|
+
|
|
315
|
+
<!-- Action column -->
|
|
316
|
+
<div class="cell cell-action">
|
|
317
|
+
{#if canExclude}
|
|
318
|
+
{@const [applyLabel, skipLabel] = actionLabels(row.node.status)}
|
|
319
|
+
<div class="join">
|
|
320
|
+
<button
|
|
321
|
+
class="join-item btn btn-xs {!isExcluded ? (row.node.status === 'removed' ? 'btn-error' : row.node.status === 'added' ? 'btn-success' : row.node.status === 'modified' ? 'btn-warning' : '') : ''}"
|
|
322
|
+
class:btn-active={!isExcluded}
|
|
323
|
+
onclick={(e) => { e.stopPropagation(); if (isExcluded) toggleExclude(key); }}
|
|
324
|
+
>{applyLabel}</button>
|
|
325
|
+
<button
|
|
326
|
+
class="join-item btn btn-xs"
|
|
327
|
+
class:btn-active={isExcluded}
|
|
328
|
+
onclick={(e) => { e.stopPropagation(); if (!isExcluded) toggleExclude(key); }}
|
|
329
|
+
>{skipLabel}</button>
|
|
330
|
+
</div>
|
|
331
|
+
{/if}
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
|
|
335
|
+
{:else}
|
|
336
|
+
<!-- Field row -->
|
|
337
|
+
<div
|
|
338
|
+
class="grid-row field-row"
|
|
339
|
+
class:field-added={row.status === 'added'}
|
|
340
|
+
class:field-removed={row.status === 'removed'}
|
|
341
|
+
class:field-changed={row.status === 'changed'}
|
|
342
|
+
>
|
|
343
|
+
<div class="cell cell-tree cell-field-name" style:padding-left="{row.depth * 1.25 + 1.25}rem">
|
|
344
|
+
{row.fieldName}
|
|
345
|
+
</div>
|
|
346
|
+
<div class="cell cell-val cell-existing"><pre class="val">{row.existing}</pre></div>
|
|
347
|
+
<div class="cell cell-val cell-pending"><pre class="val">{row.pending}</pre></div>
|
|
348
|
+
<div class="cell cell-val cell-result"><pre class="val">{row.result}</pre></div>
|
|
349
|
+
<div class="cell cell-action"></div>
|
|
350
|
+
</div>
|
|
351
|
+
{/if}
|
|
352
|
+
{/each}
|
|
353
|
+
</div>
|
|
354
|
+
</div>
|
|
355
|
+
|
|
356
|
+
<style>
|
|
357
|
+
.diff-grid-wrapper {
|
|
358
|
+
font-size: var(--rs-fs-xs, 0.75rem);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
.summary-panel {
|
|
362
|
+
margin-bottom: 0.75rem;
|
|
363
|
+
padding: 0.75rem;
|
|
364
|
+
background: rgba(255, 255, 255, 0.03);
|
|
365
|
+
border-radius: 0.5rem;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.summary-heading {
|
|
369
|
+
font-size: 0.65rem;
|
|
370
|
+
text-transform: uppercase;
|
|
371
|
+
letter-spacing: 0.05em;
|
|
372
|
+
opacity: 0.4;
|
|
373
|
+
margin-bottom: 0.5rem;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
.summary-rows {
|
|
377
|
+
display: flex;
|
|
378
|
+
flex-direction: column;
|
|
379
|
+
gap: 0.375rem;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
.summary-row {
|
|
383
|
+
display: flex;
|
|
384
|
+
align-items: center;
|
|
385
|
+
gap: 0.75rem;
|
|
386
|
+
font-size: var(--rs-fs-sm, 0.875rem);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
.summary-action {
|
|
390
|
+
font-weight: 600;
|
|
391
|
+
min-width: 6rem;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
.summary-skip {
|
|
395
|
+
opacity: 0.4;
|
|
396
|
+
min-width: 5rem;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
.summary-total {
|
|
400
|
+
opacity: 0.3;
|
|
401
|
+
font-size: var(--rs-fs-xs, 0.75rem);
|
|
402
|
+
min-width: 3rem;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
.summary-btns {
|
|
406
|
+
margin-left: auto;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
.batch-type-row {
|
|
410
|
+
display: flex;
|
|
411
|
+
align-items: center;
|
|
412
|
+
justify-content: space-between;
|
|
413
|
+
gap: 0.5rem;
|
|
414
|
+
padding: 0.25rem 0.5rem;
|
|
415
|
+
border-radius: 0.25rem;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
.batch-type-row:hover {
|
|
419
|
+
background: rgba(255, 255, 255, 0.05);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
.batch-type-name {
|
|
423
|
+
font-size: var(--rs-fs-xs, 0.75rem);
|
|
424
|
+
white-space: nowrap;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/* --- Grid layout --- */
|
|
428
|
+
.grid-header {
|
|
429
|
+
display: grid;
|
|
430
|
+
grid-template-columns: minmax(250px, 1.5fr) 1fr 1fr 1fr auto;
|
|
431
|
+
gap: 0;
|
|
432
|
+
padding: 0 0.5rem;
|
|
433
|
+
border-bottom: 1px solid oklch(var(--bc) / 0.1);
|
|
434
|
+
margin-bottom: 0.25rem;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
.col-head {
|
|
438
|
+
font-size: 0.65rem;
|
|
439
|
+
text-transform: uppercase;
|
|
440
|
+
letter-spacing: 0.05em;
|
|
441
|
+
opacity: 0.4;
|
|
442
|
+
padding: 0.25rem 0.75rem 0.375rem;
|
|
443
|
+
white-space: nowrap;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
.col-head.col-tree,
|
|
447
|
+
.col-head.col-action {
|
|
448
|
+
opacity: 0;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
.grid-body {
|
|
452
|
+
display: flex;
|
|
453
|
+
flex-direction: column;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
.grid-row {
|
|
457
|
+
display: grid;
|
|
458
|
+
grid-template-columns: minmax(250px, 1.5fr) 1fr 1fr 1fr auto;
|
|
459
|
+
gap: 0;
|
|
460
|
+
border-radius: 0.25rem;
|
|
461
|
+
min-height: 1.75rem;
|
|
462
|
+
align-items: start;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
.entity-row {
|
|
466
|
+
align-items: center;
|
|
467
|
+
background: rgba(255, 255, 255, 0.03);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
.field-row {
|
|
471
|
+
background: rgba(255, 255, 255, 0.02);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
.grid-row:hover {
|
|
475
|
+
background: rgba(255, 255, 255, 0.06);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/* --- Outcome-based row colors (stoplight: green=add, yellow=update, red=delete) --- */
|
|
479
|
+
.entity-row.outcome-add {
|
|
480
|
+
background: oklch(var(--su) / 0.06);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
.entity-row.outcome-update {
|
|
484
|
+
background: oklch(var(--wa) / 0.04);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
.entity-row.outcome-delete {
|
|
488
|
+
background: oklch(var(--er) / 0.06);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
.entity-row.outcome-skip {
|
|
492
|
+
opacity: 0.4;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
.field-row.field-added {
|
|
496
|
+
background: oklch(var(--su) / 0.05);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
.field-row.field-removed {
|
|
500
|
+
background: oklch(var(--er) / 0.05);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
.field-row.field-changed {
|
|
504
|
+
background: oklch(var(--wa) / 0.05);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/* --- Cells --- */
|
|
508
|
+
.cell {
|
|
509
|
+
padding: 0.25rem 0.75rem;
|
|
510
|
+
font-size: inherit;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
.cell-tree {
|
|
514
|
+
white-space: nowrap;
|
|
515
|
+
overflow: hidden;
|
|
516
|
+
text-overflow: ellipsis;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
.cell-val {
|
|
520
|
+
font-family: monospace;
|
|
521
|
+
min-width: 0;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
.val {
|
|
525
|
+
margin: 0;
|
|
526
|
+
font-family: inherit;
|
|
527
|
+
font-size: inherit;
|
|
528
|
+
white-space: pre-wrap;
|
|
529
|
+
word-break: break-word;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
.tree-toggle {
|
|
533
|
+
display: inline-flex;
|
|
534
|
+
align-items: center;
|
|
535
|
+
gap: 0.375rem;
|
|
536
|
+
background: none;
|
|
537
|
+
border: none;
|
|
538
|
+
padding: 0;
|
|
539
|
+
cursor: pointer;
|
|
540
|
+
text-align: left;
|
|
541
|
+
color: inherit;
|
|
542
|
+
font-size: inherit;
|
|
543
|
+
width: 100%;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
.tree-toggle:disabled {
|
|
547
|
+
cursor: default;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
.chevron {
|
|
551
|
+
font-size: 0.6rem;
|
|
552
|
+
width: 0.75rem;
|
|
553
|
+
flex-shrink: 0;
|
|
554
|
+
opacity: 0.5;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
.chevron-spacer {
|
|
558
|
+
width: 0.75rem;
|
|
559
|
+
flex-shrink: 0;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
.entity-type {
|
|
563
|
+
opacity: 0.45;
|
|
564
|
+
font-size: 0.65rem;
|
|
565
|
+
flex-shrink: 0;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
.entity-name {
|
|
569
|
+
font-weight: 500;
|
|
570
|
+
white-space: nowrap;
|
|
571
|
+
overflow: hidden;
|
|
572
|
+
text-overflow: ellipsis;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
.cell-field-name {
|
|
576
|
+
opacity: 0.6;
|
|
577
|
+
font-family: monospace;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/* --- Value column colors (field rows) --- */
|
|
581
|
+
.field-removed .cell-existing {
|
|
582
|
+
color: oklch(var(--er));
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
.field-added .cell-pending,
|
|
586
|
+
.field-added .cell-result {
|
|
587
|
+
color: oklch(var(--su));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
.field-changed .cell-existing {
|
|
591
|
+
color: oklch(var(--er) / 0.8);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
.field-changed .cell-pending,
|
|
595
|
+
.field-changed .cell-result {
|
|
596
|
+
color: oklch(var(--wa));
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/* --- Action button --- */
|
|
600
|
+
.cell-action {
|
|
601
|
+
font-family: inherit;
|
|
602
|
+
padding: 0.25rem 0.5rem;
|
|
603
|
+
white-space: nowrap;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
</style>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ExportedEntity, DiffStatus, FieldDiff } from './entity-diff.js';
|
|
2
|
+
|
|
3
|
+
export interface ApplyPlan {
|
|
4
|
+
toSet: ExportedEntity[]; // Added + modified, parents first
|
|
5
|
+
toDel: ExportedEntity[]; // Shallowest removed entities only
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compute the minimal SET/DEL operations to apply an import.
|
|
10
|
+
*
|
|
11
|
+
* @param diffs - Flat diff map from diffEntityLists()
|
|
12
|
+
* @param allFkFields - All FK field names used in BelongsTo relationships (for cascade detection)
|
|
13
|
+
* @param excluded - Optional set of "Type:id" keys to skip (user chose to keep current version)
|
|
14
|
+
*/
|
|
15
|
+
export function computeApplyPlan(
|
|
16
|
+
diffs: Map<string, { status: DiffStatus; fields?: FieldDiff[]; entity: ExportedEntity }>,
|
|
17
|
+
allFkFields: string[],
|
|
18
|
+
excluded?: Set<string>,
|
|
19
|
+
): ApplyPlan {
|
|
20
|
+
const toSet: ExportedEntity[] = [];
|
|
21
|
+
const allRemoved = new Map<string, ExportedEntity>();
|
|
22
|
+
|
|
23
|
+
for (const [key, diff] of diffs) {
|
|
24
|
+
if (excluded?.has(key)) continue;
|
|
25
|
+
if (diff.status === 'added' || diff.status === 'modified') {
|
|
26
|
+
toSet.push(diff.entity);
|
|
27
|
+
} else if (diff.status === 'removed') {
|
|
28
|
+
allRemoved.set(diff.entity.data.id as string, diff.entity);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Filter removed to shallowest only — if a removed entity's parent is also
|
|
33
|
+
// removed, skip it (the cascade will handle it)
|
|
34
|
+
const removedIds = new Set(allRemoved.keys());
|
|
35
|
+
const toDel: ExportedEntity[] = [];
|
|
36
|
+
for (const [_id, entity] of allRemoved) {
|
|
37
|
+
const hasRemovedParent = allFkFields.some((fk) => {
|
|
38
|
+
const parentId = entity.data[fk] as string | undefined;
|
|
39
|
+
return parentId && removedIds.has(parentId);
|
|
40
|
+
});
|
|
41
|
+
if (!hasRemovedParent) {
|
|
42
|
+
toDel.push(entity);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { toSet, toDel };
|
|
47
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/** Diff status for an entity or field. */
|
|
2
|
+
export type DiffStatus = 'added' | 'removed' | 'modified' | 'unchanged';
|
|
3
|
+
|
|
4
|
+
/** Field-level diff for a modified entity. */
|
|
5
|
+
export interface FieldDiff {
|
|
6
|
+
field: string;
|
|
7
|
+
oldValue?: unknown;
|
|
8
|
+
newValue?: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Entity-level diff node with children for tree display. */
|
|
12
|
+
export interface EntityDiff {
|
|
13
|
+
type: string;
|
|
14
|
+
id: string;
|
|
15
|
+
status: DiffStatus;
|
|
16
|
+
/** Display label (entity name or id). */
|
|
17
|
+
name?: string;
|
|
18
|
+
/** Field-level changes — only populated for 'modified' status. */
|
|
19
|
+
fields?: FieldDiff[];
|
|
20
|
+
/** The current (server) entity data — only populated for 'modified' status. */
|
|
21
|
+
currentData?: Record<string, unknown>;
|
|
22
|
+
/** The incoming entity data — only populated for 'modified' status. */
|
|
23
|
+
incomingData?: Record<string, unknown>;
|
|
24
|
+
children: EntityDiff[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A single exported entity from an EntityTreeExport. */
|
|
28
|
+
export interface ExportedEntity {
|
|
29
|
+
entityType: string;
|
|
30
|
+
data: Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Compare two flat entity lists and produce field-level diffs.
|
|
35
|
+
*
|
|
36
|
+
* @param current - Entities currently on the server
|
|
37
|
+
* @param incoming - Entities from the import source (file or snapshot)
|
|
38
|
+
* @returns Map of "type:id" -> { status, fields }
|
|
39
|
+
*/
|
|
40
|
+
export function diffEntityLists(
|
|
41
|
+
current: ExportedEntity[],
|
|
42
|
+
incoming: ExportedEntity[],
|
|
43
|
+
): Map<string, { status: DiffStatus; fields?: FieldDiff[]; entity: ExportedEntity }> {
|
|
44
|
+
const currentMap = new Map<string, ExportedEntity>();
|
|
45
|
+
for (const e of current) {
|
|
46
|
+
currentMap.set(`${e.entityType}:${e.data.id}`, e);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const incomingMap = new Map<string, ExportedEntity>();
|
|
50
|
+
for (const e of incoming) {
|
|
51
|
+
incomingMap.set(`${e.entityType}:${e.data.id}`, e);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const result = new Map<
|
|
55
|
+
string,
|
|
56
|
+
{ status: DiffStatus; fields?: FieldDiff[]; entity: ExportedEntity; currentEntity?: ExportedEntity }
|
|
57
|
+
>();
|
|
58
|
+
|
|
59
|
+
// Check incoming entities against current
|
|
60
|
+
for (const [key, inc] of incomingMap) {
|
|
61
|
+
const cur = currentMap.get(key);
|
|
62
|
+
if (!cur) {
|
|
63
|
+
result.set(key, { status: 'added', entity: inc });
|
|
64
|
+
} else {
|
|
65
|
+
const fields = diffFields(cur.data, inc.data);
|
|
66
|
+
if (fields.length > 0) {
|
|
67
|
+
result.set(key, { status: 'modified', fields, entity: inc, currentEntity: cur });
|
|
68
|
+
} else {
|
|
69
|
+
result.set(key, { status: 'unchanged', entity: inc });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Check for removed entities (in current but not in incoming)
|
|
75
|
+
for (const [key, cur] of currentMap) {
|
|
76
|
+
if (!incomingMap.has(key)) {
|
|
77
|
+
result.set(key, { status: 'removed', entity: cur });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Compare two entity data objects field-by-field. */
|
|
85
|
+
function diffFields(
|
|
86
|
+
oldData: Record<string, unknown>,
|
|
87
|
+
newData: Record<string, unknown>,
|
|
88
|
+
): FieldDiff[] {
|
|
89
|
+
const diffs: FieldDiff[] = [];
|
|
90
|
+
const allKeys = new Set([...Object.keys(oldData), ...Object.keys(newData)]);
|
|
91
|
+
|
|
92
|
+
for (const key of allKeys) {
|
|
93
|
+
// Skip hash — it's derived and always changes when fields change
|
|
94
|
+
if (key === 'hash') continue;
|
|
95
|
+
|
|
96
|
+
const oldVal = oldData[key];
|
|
97
|
+
const newVal = newData[key];
|
|
98
|
+
|
|
99
|
+
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
|
|
100
|
+
diffs.push({ field: key, oldValue: oldVal, newValue: newVal });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return diffs;
|
|
105
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { EntityDiff, ExportedEntity, DiffStatus, FieldDiff } from './entity-diff.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Maps child entity type to its parent FK field names (camelCase).
|
|
5
|
+
* Provided by the consuming application — generated from Rust relationship registrations.
|
|
6
|
+
*/
|
|
7
|
+
export type ParentFkFields = Record<string, string[]>;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Extract a display name from entity data.
|
|
11
|
+
* Tries 'name' field first, falls back to 'id'.
|
|
12
|
+
*/
|
|
13
|
+
function entityName(data: Record<string, unknown>): string {
|
|
14
|
+
return (data.name as string) ?? (data.id as string) ?? 'unknown';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build an EntityDiff tree from a flat diff map.
|
|
19
|
+
*
|
|
20
|
+
* Entities with known parent FK fields are placed under their parent.
|
|
21
|
+
* Entities without a parent mapping (or whose parent isn't in the diff)
|
|
22
|
+
* are placed as direct children of the root so they remain visible.
|
|
23
|
+
*
|
|
24
|
+
* @param rootType - The root entity type
|
|
25
|
+
* @param rootId - The root entity ID
|
|
26
|
+
* @param diffs - Flat diff map from diffEntityLists()
|
|
27
|
+
* @returns Root EntityDiff node with nested children
|
|
28
|
+
*/
|
|
29
|
+
export function buildDiffTree(
|
|
30
|
+
rootType: string,
|
|
31
|
+
rootId: string,
|
|
32
|
+
diffs: Map<string, { status: DiffStatus; fields?: FieldDiff[]; entity: ExportedEntity; currentEntity?: ExportedEntity }>,
|
|
33
|
+
parentFkFields: ParentFkFields,
|
|
34
|
+
): EntityDiff {
|
|
35
|
+
// Build flat EntityDiff nodes and index by ID for parent lookup
|
|
36
|
+
const allNodes = new Map<string, EntityDiff>();
|
|
37
|
+
const idToKey = new Map<string, string>();
|
|
38
|
+
const childrenByParent = new Map<string, EntityDiff[]>();
|
|
39
|
+
const placed = new Set<string>();
|
|
40
|
+
|
|
41
|
+
for (const [key, diff] of diffs) {
|
|
42
|
+
const [type, id] = key.split(':');
|
|
43
|
+
const node: EntityDiff = {
|
|
44
|
+
type,
|
|
45
|
+
id,
|
|
46
|
+
status: diff.status,
|
|
47
|
+
name: entityName(diff.entity.data),
|
|
48
|
+
fields: diff.fields,
|
|
49
|
+
currentData: diff.currentEntity?.data ?? (diff.status === 'removed' ? diff.entity.data : undefined),
|
|
50
|
+
incomingData: diff.status === 'added' || diff.status === 'modified' ? diff.entity.data : undefined,
|
|
51
|
+
children: [],
|
|
52
|
+
};
|
|
53
|
+
allNodes.set(key, node);
|
|
54
|
+
idToKey.set(id, key);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Build parent-child relationships
|
|
58
|
+
for (const [key, diff] of diffs) {
|
|
59
|
+
const [type] = key.split(':');
|
|
60
|
+
const fkFields = parentFkFields[type];
|
|
61
|
+
if (fkFields) {
|
|
62
|
+
let foundParent = false;
|
|
63
|
+
for (const fkField of fkFields) {
|
|
64
|
+
const parentId = diff.entity.data[fkField] as string | undefined;
|
|
65
|
+
if (parentId) {
|
|
66
|
+
const parentKey = idToKey.get(parentId);
|
|
67
|
+
if (parentKey) {
|
|
68
|
+
if (!childrenByParent.has(parentKey)) {
|
|
69
|
+
childrenByParent.set(parentKey, []);
|
|
70
|
+
}
|
|
71
|
+
childrenByParent.get(parentKey)!.push(allNodes.get(key)!);
|
|
72
|
+
placed.add(key);
|
|
73
|
+
foundParent = true;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Get or create root node
|
|
82
|
+
const rootKey = `${rootType}:${rootId}`;
|
|
83
|
+
const rootNode = allNodes.get(rootKey) ?? {
|
|
84
|
+
type: rootType,
|
|
85
|
+
id: rootId,
|
|
86
|
+
status: 'unchanged' as DiffStatus,
|
|
87
|
+
name: rootId,
|
|
88
|
+
children: [],
|
|
89
|
+
};
|
|
90
|
+
placed.add(rootKey);
|
|
91
|
+
|
|
92
|
+
// Attach children from FK relationships
|
|
93
|
+
for (const [key, node] of allNodes) {
|
|
94
|
+
node.children = childrenByParent.get(key) ?? [];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Orphaned entities (not the root, not placed under any parent) become root children
|
|
98
|
+
for (const [key, node] of allNodes) {
|
|
99
|
+
if (!placed.has(key)) {
|
|
100
|
+
rootNode.children.push(node);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Deduplicate all children arrays — entities reachable via multiple FK paths
|
|
105
|
+
// (e.g., BindingNodeConnection with both start_node_id and end_node_id) could
|
|
106
|
+
// appear more than once
|
|
107
|
+
for (const node of allNodes.values()) {
|
|
108
|
+
if (node.children.length > 0) {
|
|
109
|
+
const seen = new Set<string>();
|
|
110
|
+
node.children = node.children.filter((c) => {
|
|
111
|
+
const k = `${c.type}:${c.id}`;
|
|
112
|
+
if (seen.has(k)) return false;
|
|
113
|
+
seen.add(k);
|
|
114
|
+
return true;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Also dedup root if it was created fresh (not from allNodes)
|
|
119
|
+
if (rootNode.children.length > 0) {
|
|
120
|
+
const seen = new Set<string>();
|
|
121
|
+
rootNode.children = rootNode.children.filter((c) => {
|
|
122
|
+
const k = `${c.type}:${c.id}`;
|
|
123
|
+
if (seen.has(k)) return false;
|
|
124
|
+
seen.add(k);
|
|
125
|
+
return true;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return rootNode;
|
|
130
|
+
}
|