@av-controls/reconcile-ui 0.5.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 ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@av-controls/reconcile-ui",
3
+ "version": "0.5.2",
4
+ "description": "Shared Vue UI for reconciling saved control state against a current spec",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "src"
14
+ ],
15
+ "dependencies": {
16
+ "@av-controls/protocol": "^0.5.2"
17
+ },
18
+ "peerDependencies": {
19
+ "vue": "^3.5.21"
20
+ },
21
+ "author": "felix niemeyer",
22
+ "license": "MIT",
23
+ "publishConfig": {
24
+ "access": "public"
25
+ }
26
+ }
@@ -0,0 +1,276 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+ import type { Reconcile } from '@av-controls/protocol'
4
+
5
+ const props = defineProps<{
6
+ diff: Reconcile.ReconcileDiff
7
+ modelValue: Reconcile.MatchMap
8
+ }>()
9
+
10
+ const emit = defineEmits<{
11
+ confirm: [map: Reconcile.MatchMap]
12
+ cancel: []
13
+ }>()
14
+
15
+ const working = ref<Reconcile.MatchMap>({ ...props.modelValue })
16
+ const selectedOrphan = ref<string | null>(null)
17
+
18
+ const specByPath = computed(() => new Map(props.diff.onlyInSpec.map(leaf => [leaf.path, leaf])))
19
+ const orphanByPath = computed(() => new Map(props.diff.onlyInFile.map(leaf => [leaf.path, leaf])))
20
+
21
+ function leafName(path: string) {
22
+ const segments = path.split('/')
23
+ return segments[segments.length - 1] ?? path
24
+ }
25
+
26
+ type Row = {
27
+ key: string
28
+ spec?: Reconcile.SpecLeaf
29
+ orphan?: Reconcile.FileLeaf
30
+ orphanPath?: string
31
+ }
32
+
33
+ const rows = computed<Row[]>(() => {
34
+ const usedSpec = new Set(Object.values(working.value))
35
+ const mappedOrphans = new Set(Object.keys(working.value))
36
+
37
+ const paired: Row[] = Object.entries(working.value).map(([orphanPath, specPath]) => ({
38
+ key: `pair:${orphanPath}`,
39
+ orphan: orphanByPath.value.get(orphanPath),
40
+ orphanPath,
41
+ spec: specByPath.value.get(specPath),
42
+ }))
43
+ const specOnly: Row[] = props.diff.onlyInSpec
44
+ .filter(spec => !usedSpec.has(spec.path))
45
+ .map(spec => ({ key: `spec:${spec.path}`, spec }))
46
+ const orphanOnly: Row[] = props.diff.onlyInFile
47
+ .filter(orphan => !mappedOrphans.has(orphan.path))
48
+ .map(orphan => ({ key: `orphan:${orphan.path}`, orphan, orphanPath: orphan.path }))
49
+
50
+ return [...paired, ...specOnly, ...orphanOnly]
51
+ })
52
+
53
+ const droppedCount = computed(
54
+ () => props.diff.onlyInFile.filter(o => !(o.path in working.value)).length,
55
+ )
56
+
57
+ function isSelected(path?: string) {
58
+ return !!path && selectedOrphan.value === path
59
+ }
60
+
61
+ function compatible(spec: Reconcile.SpecLeaf) {
62
+ if (!selectedOrphan.value) return false
63
+ const orphan = orphanByPath.value.get(selectedOrphan.value)
64
+ if (orphan?.type && spec.type && orphan.type !== spec.type) return false
65
+ return true
66
+ }
67
+
68
+ function onOrphanClick(path?: string) {
69
+ if (!path) return
70
+ selectedOrphan.value = selectedOrphan.value === path ? null : path
71
+ }
72
+
73
+ function onSpecClick(spec: Reconcile.SpecLeaf) {
74
+ if (!selectedOrphan.value || !compatible(spec)) return
75
+ // a spec target is used by at most one orphan
76
+ for (const [orphanPath, specPath] of Object.entries(working.value)) {
77
+ if (specPath === spec.path) delete working.value[orphanPath]
78
+ }
79
+ working.value[selectedOrphan.value] = spec.path
80
+ selectedOrphan.value = null
81
+ }
82
+
83
+ function clearMapping(orphanPath?: string) {
84
+ if (orphanPath) delete working.value[orphanPath]
85
+ }
86
+ </script>
87
+
88
+ <template>
89
+ <div class="reconcile">
90
+ <div class="reconcile-head">
91
+ <h3>Match saved values to current controls</h3>
92
+ <p>
93
+ {{ diff.matched.length }} matched automatically.
94
+ Connect orphaned saved values (right) to current controls (left).
95
+ Unconnected values ({{ droppedCount }}) will be dropped.
96
+ </p>
97
+ </div>
98
+
99
+ <div class="reconcile-cols-head">
100
+ <span class="col-spec">Current controls (no saved value)</span>
101
+ <span></span>
102
+ <span class="col-orphan">Saved values (no matching control)</span>
103
+ </div>
104
+
105
+ <div class="reconcile-rows">
106
+ <div
107
+ v-for="row in rows"
108
+ :key="row.key"
109
+ class="reconcile-row"
110
+ :class="{ paired: row.spec && row.orphan }"
111
+ >
112
+ <button
113
+ v-if="row.spec"
114
+ class="cell cell-spec"
115
+ :class="{ targetable: selectedOrphan && compatible(row.spec), disabled: selectedOrphan && !compatible(row.spec) }"
116
+ @click="onSpecClick(row.spec)"
117
+ >
118
+ <span class="name">{{ leafName(row.spec.path) }}</span>
119
+ <span class="path">{{ row.spec.path }}</span>
120
+ <span v-if="row.spec.type" class="type">{{ row.spec.type }}</span>
121
+ </button>
122
+ <span v-else class="cell cell-empty"></span>
123
+
124
+ <span class="connector">
125
+ <button
126
+ v-if="row.spec && row.orphan"
127
+ class="break"
128
+ title="Break this match"
129
+ @click="clearMapping(row.orphanPath)"
130
+ >↔</button>
131
+ </span>
132
+
133
+ <button
134
+ v-if="row.orphan"
135
+ class="cell cell-orphan"
136
+ :class="{ selected: isSelected(row.orphanPath) }"
137
+ @click="onOrphanClick(row.orphanPath)"
138
+ >
139
+ <span class="name">{{ leafName(row.orphan.path) }}</span>
140
+ <span class="path">{{ row.orphan.path }}</span>
141
+ <span v-if="row.orphan.type" class="type">{{ row.orphan.type }}</span>
142
+ </button>
143
+ <span v-else class="cell cell-empty"></span>
144
+ </div>
145
+ </div>
146
+
147
+ <div class="reconcile-actions">
148
+ <button class="btn cancel" @click="emit('cancel')">Cancel</button>
149
+ <button class="btn apply" @click="emit('confirm', { ...working })">Apply &amp; import</button>
150
+ </div>
151
+ </div>
152
+ </template>
153
+
154
+ <style scoped>
155
+ .reconcile {
156
+ display: flex;
157
+ flex-direction: column;
158
+ gap: 12px;
159
+ padding: 16px;
160
+ max-height: 80vh;
161
+ color: #d8dde2;
162
+
163
+ & h3 {
164
+ margin: 0;
165
+ }
166
+
167
+ & p {
168
+ margin: 4px 0 0;
169
+ opacity: 0.75;
170
+ font-size: 0.85em;
171
+ }
172
+
173
+ & .reconcile-cols-head,
174
+ & .reconcile-row {
175
+ display: grid;
176
+ grid-template-columns: 1fr 40px 1fr;
177
+ align-items: stretch;
178
+ gap: 8px;
179
+ }
180
+
181
+ & .reconcile-cols-head {
182
+ font-size: 0.8em;
183
+ opacity: 0.7;
184
+
185
+ & .col-spec { color: #9cd6a3; }
186
+ & .col-orphan { color: #d6b78a; text-align: right; }
187
+ }
188
+
189
+ & .reconcile-rows {
190
+ overflow-y: auto;
191
+ display: flex;
192
+ flex-direction: column;
193
+ gap: 6px;
194
+ }
195
+
196
+ & .cell {
197
+ display: flex;
198
+ flex-direction: column;
199
+ text-align: left;
200
+ gap: 2px;
201
+ padding: 6px 8px;
202
+ border-radius: 4px;
203
+ border: 1px solid transparent;
204
+ background: rgba(255, 255, 255, 0.04);
205
+ color: inherit;
206
+ cursor: default;
207
+
208
+ & .name { font-weight: 600; }
209
+ & .path { font-size: 0.75em; opacity: 0.6; }
210
+ & .type { font-size: 0.7em; opacity: 0.5; }
211
+ }
212
+
213
+ & .cell-empty {
214
+ background: transparent;
215
+ }
216
+
217
+ & .cell-spec {
218
+ border-color: rgba(156, 214, 163, 0.3);
219
+
220
+ &.targetable {
221
+ cursor: pointer;
222
+ border-color: rgba(156, 214, 163, 0.8);
223
+ background: rgba(156, 214, 163, 0.12);
224
+ }
225
+
226
+ &.disabled {
227
+ opacity: 0.35;
228
+ }
229
+ }
230
+
231
+ & .cell-orphan {
232
+ cursor: pointer;
233
+ border-color: rgba(214, 183, 138, 0.3);
234
+
235
+ &.selected {
236
+ border-color: rgba(214, 183, 138, 0.95);
237
+ background: rgba(214, 183, 138, 0.16);
238
+ }
239
+ }
240
+
241
+ & .connector {
242
+ display: flex;
243
+ align-items: center;
244
+ justify-content: center;
245
+
246
+ & .link { opacity: 0.6; }
247
+
248
+ & .break {
249
+ border: none;
250
+ background: transparent;
251
+ color: #d68a8a;
252
+ cursor: pointer;
253
+ }
254
+ }
255
+
256
+ & .reconcile-actions {
257
+ display: flex;
258
+ justify-content: flex-end;
259
+ gap: 8px;
260
+
261
+ & .btn {
262
+ padding: 6px 14px;
263
+ border-radius: 4px;
264
+ border: 1px solid rgba(255, 255, 255, 0.2);
265
+ background: rgba(255, 255, 255, 0.06);
266
+ color: inherit;
267
+ cursor: pointer;
268
+ }
269
+
270
+ & .apply {
271
+ border-color: rgba(156, 214, 163, 0.6);
272
+ background: rgba(156, 214, 163, 0.18);
273
+ }
274
+ }
275
+ }
276
+ </style>
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default as ReconcileMatcher } from './ReconcileMatcher.vue'