@quazardous/qdadm 2.32.0 → 2.33.1

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.
Files changed (41) hide show
  1. package/dist/styles/qdadm.css +1 -1
  2. package/package.json +9 -2
  3. package/src/components/display/EmailPreview.vue +227 -0
  4. package/src/components/display/EntityTimeline.vue +408 -0
  5. package/src/components/display/LogStream.vue +364 -0
  6. package/src/components/display/PayloadDiff.vue +236 -0
  7. package/src/components/display/PayloadViewer.vue +246 -0
  8. package/src/components/index.ts +8 -0
  9. package/src/components/layout/AppLayout.vue +8 -0
  10. package/src/components/tasks/TaskDialog.vue +95 -0
  11. package/src/components/tasks/TaskHost.vue +45 -0
  12. package/src/components/tasks/TaskTray.vue +54 -0
  13. package/src/composables/index.ts +11 -0
  14. package/src/composables/useListPage.filters.ts +26 -1
  15. package/src/composables/useLogStream.ts +264 -0
  16. package/src/email/index.ts +46 -0
  17. package/src/entity/storage/SdkStorage.ts +53 -1
  18. package/src/entity/storage/index.ts +2 -0
  19. package/src/i18n/defaults/core.en.generated.ts +85 -0
  20. package/src/i18n/defaults/core.en.yml +85 -0
  21. package/src/i18n/defaults/core.fr.generated.ts +85 -0
  22. package/src/i18n/defaults/core.fr.yml +85 -0
  23. package/src/index.ts +21 -0
  24. package/src/kernel/Kernel.vue.ts +9 -2
  25. package/src/kernel/Module.ts +3 -2
  26. package/src/modules/debug/DebugModule.ts +3 -1
  27. package/src/modules/debug/styles.generated.css +2895 -0
  28. package/src/notifications/NotificationModule.ts +1 -1
  29. package/src/notifications/{styles.scss → styles.css} +5 -5
  30. package/src/styles/_email.scss +188 -0
  31. package/src/styles/_log-stream.scss +167 -0
  32. package/src/styles/_payload.scss +312 -0
  33. package/src/styles/_tasks.scss +212 -0
  34. package/src/styles/_timeline.scss +229 -0
  35. package/src/styles/index.scss +5 -0
  36. package/src/tasks/taskStore.ts +460 -0
  37. package/src/tasks/useTaskRunner.ts +52 -0
  38. package/src/utils/ansi.ts +127 -0
  39. package/src/utils/email.ts +270 -0
  40. package/src/utils/index.ts +32 -0
  41. package/src/utils/payload.ts +480 -0
@@ -0,0 +1,364 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * LogStream - live tail of a backend log (#4110)
4
+ *
5
+ * Shows a task's log as the worker writes it, then the whole log once done.
6
+ *
7
+ * Usage:
8
+ * <LogStream :source="{ poll: (from) => fetchLog(task.id, from) }" error-pattern="ERROR|FATAL" />
9
+ * <LogStream :stream="myStream" height="60vh" /> // a useLogStream() the page already holds
10
+ *
11
+ * - follow: on by default; scrolling up pauses it, scrolling back to the end
12
+ * (or End, or the "new lines" button) resumes it.
13
+ * - Without wrapping, only the rows on screen are rendered (fixed line
14
+ * height), so a log of tens of thousands of lines scrolls freely. With
15
+ * wrapping, rows have no fixed height: every row is in the DOM and the
16
+ * browser skips painting the ones off screen (content-visibility).
17
+ */
18
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
19
+ import InputText from 'primevue/inputtext'
20
+ import QdButton from '../base/QdButton.vue'
21
+ import { useI18n } from '../../i18n/useI18n'
22
+ import { ansiStyleToCss } from '../../utils/ansi'
23
+ import { useLogStream, type LogLine, type LogSource, type UseLogStreamReturn } from '../../composables/useLogStream'
24
+
25
+ interface Props {
26
+ /** Where the lines come from; ignored when `stream` is given. */
27
+ source?: LogSource | null
28
+ /** A stream the page already holds (shared with another view). */
29
+ stream?: UseLogStreamReturn | null
30
+ /** Lines matching it are marked, counted, and reachable from the toolbar. */
31
+ errorPattern?: RegExp | string | null
32
+ /** A timestamp at the start of a line, shown dimmed. */
33
+ timestampPattern?: RegExp | string | null
34
+ height?: string
35
+ /** Row height in px, without wrapping. Matches the stylesheet's line-height. */
36
+ lineHeight?: number
37
+ wrap?: boolean
38
+ follow?: boolean
39
+ lineNumbers?: boolean
40
+ filename?: string
41
+ /** Download with the ANSI escapes kept. */
42
+ downloadRaw?: boolean
43
+ maxLines?: number
44
+ }
45
+
46
+ const props = withDefaults(defineProps<Props>(), {
47
+ source: null,
48
+ stream: null,
49
+ errorPattern: null,
50
+ timestampPattern: null,
51
+ height: '24rem',
52
+ lineHeight: 20,
53
+ wrap: false,
54
+ follow: true,
55
+ lineNumbers: true,
56
+ filename: 'log.txt',
57
+ downloadRaw: false,
58
+ maxLines: 100_000,
59
+ })
60
+
61
+ const { t } = useI18n()
62
+
63
+ const log: UseLogStreamReturn =
64
+ props.stream ??
65
+ useLogStream(props.source ?? { lines: [] }, { errorPattern: props.errorPattern, maxLines: props.maxLines })
66
+
67
+ const viewport = ref<HTMLElement | null>(null)
68
+ const scrollTop = ref(0)
69
+ const viewportHeight = ref(0)
70
+ const wrapping = ref(props.wrap)
71
+ const following = ref(props.follow)
72
+ const unseen = ref(0)
73
+ const query = ref('')
74
+ const onlyMatches = ref(false)
75
+ const currentMatch = ref(-1)
76
+ const currentError = ref(-1)
77
+ const OVERSCAN = 20
78
+
79
+ // A stream passed in may have been built without the pattern: flag here too.
80
+ const errorRe = computed(() => {
81
+ const p = props.errorPattern
82
+ if (!p) return null
83
+ return typeof p === 'string' ? new RegExp(p, 'i') : new RegExp(p.source, p.flags.replace(/[gy]/g, ''))
84
+ })
85
+ const timestampRe = computed(() => {
86
+ const p = props.timestampPattern
87
+ if (!p) return null
88
+ const source = typeof p === 'string' ? p : p.source
89
+ return new RegExp(source.startsWith('^') ? source : `^(?:${source})`, typeof p === 'string' ? '' : p.flags.replace(/[gy]/g, ''))
90
+ })
91
+ const isError = (line: LogLine) => line.error || (errorRe.value ? errorRe.value.test(line.text) : false)
92
+
93
+ const needle = computed(() => query.value.trim().toLowerCase())
94
+ const isMatch = (line: LogLine) => needle.value !== '' && line.text.toLowerCase().includes(needle.value)
95
+
96
+ /** The lines on show: all of them, or the matching ones. */
97
+ const view = computed<LogLine[]>(() =>
98
+ onlyMatches.value && needle.value ? log.lines.value.filter(isMatch) : log.lines.value
99
+ )
100
+ const matchIndexes = computed(() => {
101
+ if (!needle.value) return []
102
+ const out: number[] = []
103
+ view.value.forEach((line, i) => isMatch(line) && out.push(i))
104
+ return out
105
+ })
106
+ const errorIndexes = computed(() => {
107
+ const out: number[] = []
108
+ view.value.forEach((line, i) => isError(line) && out.push(i))
109
+ return out
110
+ })
111
+
112
+ const visibleRows = computed(() => Math.ceil((viewportHeight.value || 400) / props.lineHeight))
113
+ // Clamped: when the view shrinks (only matches), scrollTop can still point
114
+ // past its end until the browser catches up.
115
+ const windowStart = computed(() => {
116
+ if (wrapping.value) return 0
117
+ const fromScroll = Math.floor(scrollTop.value / props.lineHeight) - OVERSCAN
118
+ return Math.max(0, Math.min(fromScroll, view.value.length - visibleRows.value - OVERSCAN))
119
+ })
120
+ const windowRows = computed(() => {
121
+ if (wrapping.value) return view.value
122
+ const visible = visibleRows.value
123
+ return view.value.slice(windowStart.value, windowStart.value + visible + OVERSCAN * 2)
124
+ })
125
+ const totalHeight = computed(() => view.value.length * props.lineHeight)
126
+
127
+ interface Piece {
128
+ text: string
129
+ style: Record<string, string>
130
+ cls: string
131
+ }
132
+
133
+ /** A row's styled segments, cut again where the timestamp and the matches start and end. */
134
+ function pieces(line: LogLine): Piece[] {
135
+ const ranges: Array<[number, number, string]> = []
136
+ const ts = timestampRe.value?.exec(line.text)
137
+ if (ts && ts[0].length > 0) ranges.push([0, ts[0].length, 'qd-log-ts'])
138
+ if (needle.value) {
139
+ const lower = line.text.toLowerCase()
140
+ for (let at = lower.indexOf(needle.value); at !== -1; at = lower.indexOf(needle.value, at + needle.value.length)) {
141
+ ranges.push([at, at + needle.value.length, 'qd-log-mark'])
142
+ }
143
+ }
144
+ const out: Piece[] = []
145
+ let offset = 0
146
+ for (const seg of line.segments) {
147
+ const style = ansiStyleToCss(seg.style)
148
+ const end = offset + seg.text.length
149
+ const cuts = new Set([offset, end])
150
+ for (const [a, b] of ranges) {
151
+ if (a > offset && a < end) cuts.add(a)
152
+ if (b > offset && b < end) cuts.add(b)
153
+ }
154
+ const sorted = [...cuts].sort((x, y) => x - y)
155
+ for (let i = 0; i < sorted.length - 1; i++) {
156
+ const a = sorted[i]!
157
+ const b = sorted[i + 1]!
158
+ const cls = ranges.filter(([ra, rb]) => ra <= a && rb >= b).map((r) => r[2]).join(' ')
159
+ out.push({ text: seg.text.slice(a - offset, b - offset), style, cls })
160
+ }
161
+ offset = end
162
+ }
163
+ return out
164
+ }
165
+
166
+ // --- Scrolling and follow ---
167
+
168
+ // No "programmatic scroll" flag: setting scrollTop to where it already is
169
+ // fires no scroll event, so such a flag would stay up and swallow the user's
170
+ // next scroll. Each scroll just asks whether the end is in view.
171
+ // Where the last scroll to the end left the viewport. Below it, the user has
172
+ // scrolled up; at it, nothing moved — including a log mounted hidden (a
173
+ // dialog not laid out yet), whose first scroll to the end did nothing.
174
+ let lastAutoTop = 0
175
+
176
+ function scrollToEnd(): void {
177
+ const el = viewport.value
178
+ if (!el) return
179
+ el.scrollTop = el.scrollHeight
180
+ scrollTop.value = el.scrollTop
181
+ lastAutoTop = el.scrollTop
182
+ }
183
+
184
+ function onScroll(): void {
185
+ const el = viewport.value
186
+ if (!el) return
187
+ scrollTop.value = el.scrollTop
188
+ const atEnd = el.scrollTop + el.clientHeight >= el.scrollHeight - props.lineHeight
189
+ following.value = atEnd
190
+ if (atEnd) unseen.value = 0
191
+ }
192
+
193
+ function resume(): void {
194
+ following.value = true
195
+ unseen.value = 0
196
+ void nextTick(scrollToEnd)
197
+ }
198
+
199
+ function onKeydown(event: KeyboardEvent): void {
200
+ if (event.key === 'End') {
201
+ event.preventDefault()
202
+ resume()
203
+ }
204
+ }
205
+
206
+ watch(
207
+ () => log.lines.value,
208
+ (next, prev) => {
209
+ const added = Math.max(0, (next.at(-1)?.n ?? 0) - (prev?.at(-1)?.n ?? 0))
210
+ // Asked of the DOM, still at its old height: the scroll event of a user
211
+ // who just scrolled up may not have arrived yet, and following on would
212
+ // yank them back down.
213
+ const el = viewport.value
214
+ if (following.value && el && el.scrollTop < lastAutoTop - props.lineHeight) {
215
+ following.value = false
216
+ }
217
+ if (following.value) void nextTick(scrollToEnd)
218
+ else unseen.value += added
219
+ }
220
+ )
221
+
222
+ /** Bring a row of the view to the middle of the viewport, and stop following. */
223
+ function reveal(index: number): void {
224
+ const el = viewport.value
225
+ if (!el) return
226
+ following.value = false
227
+ if (wrapping.value) {
228
+ void nextTick(() => {
229
+ el.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'center' })
230
+ })
231
+ return
232
+ }
233
+ el.scrollTop = Math.max(0, index * props.lineHeight - el.clientHeight / 2)
234
+ scrollTop.value = el.scrollTop
235
+ }
236
+
237
+ function step(list: number[], current: { value: number }, delta: 1 | -1): void {
238
+ if (list.length === 0) return
239
+ current.value = (current.value + delta + list.length) % list.length
240
+ reveal(list[current.value]!)
241
+ }
242
+
243
+ const nextMatch = () => step(matchIndexes.value, currentMatch, 1)
244
+ const previousMatch = () => step(matchIndexes.value, currentMatch, -1)
245
+ const nextError = () => step(errorIndexes.value, currentError, 1)
246
+
247
+ watch([needle, onlyMatches], () => {
248
+ currentMatch.value = -1
249
+ })
250
+
251
+ function toggleWrap(): void {
252
+ wrapping.value = !wrapping.value
253
+ if (following.value) void nextTick(scrollToEnd)
254
+ }
255
+
256
+ function toggleFollow(): void {
257
+ if (following.value) following.value = false
258
+ else resume()
259
+ }
260
+
261
+ // --- Download ---
262
+
263
+ function download(): void {
264
+ const body = log.lines.value.map((l) => (props.downloadRaw ? l.raw : l.text)).join('\n')
265
+ const url = URL.createObjectURL(new Blob([body + '\n'], { type: 'text/plain;charset=utf-8' }))
266
+ const a = document.createElement('a')
267
+ a.href = url
268
+ a.download = props.filename
269
+ a.click()
270
+ setTimeout(() => URL.revokeObjectURL(url), 0)
271
+ }
272
+
273
+ // --- Status ---
274
+
275
+ const statusLabel = computed(() => {
276
+ switch (log.status.value) {
277
+ case 'streaming':
278
+ return t('core.log.streaming')
279
+ case 'done':
280
+ return t('core.log.done')
281
+ case 'error':
282
+ return t('core.log.failed', { error: log.error.value ?? '' })
283
+ default:
284
+ return t('core.log.stopped')
285
+ }
286
+ })
287
+
288
+ let resizeObserver: ResizeObserver | null = null
289
+
290
+ onMounted(() => {
291
+ const el = viewport.value
292
+ if (!el) return
293
+ viewportHeight.value = el.clientHeight
294
+ if (typeof ResizeObserver !== 'undefined') {
295
+ resizeObserver = new ResizeObserver(() => {
296
+ viewportHeight.value = el.clientHeight
297
+ })
298
+ resizeObserver.observe(el)
299
+ }
300
+ if (following.value) scrollToEnd()
301
+ })
302
+
303
+ onBeforeUnmount(() => resizeObserver?.disconnect())
304
+
305
+ defineExpose({ log, resume, reveal })
306
+ </script>
307
+
308
+ <template>
309
+ <div class="qd-log-stream" :class="{ 'qd-log-stream--wrap': wrapping }">
310
+ <div class="qd-log-toolbar">
311
+ <span class="qd-log-status" :class="`qd-log-status--${log.status.value}`">
312
+ <span class="qd-log-status-dot" />
313
+ {{ statusLabel }}
314
+ </span>
315
+ <span class="qd-log-count">{{ t('core.log.lines', { count: log.lines.value.length }) }}</span>
316
+ <span class="qd-log-spacer-x" />
317
+ <span class="qd-log-search">
318
+ <InputText v-model="query" size="small" :placeholder="t('core.log.search')" :aria-label="t('core.log.search')"
319
+ @keydown.enter.prevent="$event.shiftKey ? previousMatch() : nextMatch()" />
320
+ <span v-if="needle" class="qd-log-search-count">
321
+ {{ currentMatch >= 0 ? currentMatch + 1 : 0 }}/{{ matchIndexes.length }}
322
+ </span>
323
+ <QdButton icon="pi pi-chevron-up" text rounded size="small" :disabled="!matchIndexes.length"
324
+ :aria-label="t('core.log.previous')" v-tooltip.top="t('core.log.previous')" @click="previousMatch" />
325
+ <QdButton icon="pi pi-chevron-down" text rounded size="small" :disabled="!matchIndexes.length"
326
+ :aria-label="t('core.log.next')" v-tooltip.top="t('core.log.next')" @click="nextMatch" />
327
+ <QdButton icon="pi pi-filter" rounded size="small" :text="!onlyMatches" :disabled="!needle"
328
+ :aria-label="t('core.log.onlyMatches')" :aria-pressed="onlyMatches" v-tooltip.top="t('core.log.onlyMatches')"
329
+ @click="onlyMatches = !onlyMatches" />
330
+ </span>
331
+ <QdButton v-if="errorPattern" class="qd-log-errors" icon="pi pi-exclamation-triangle" size="small" text
332
+ :severity="errorIndexes.length ? 'danger' : 'secondary'" :disabled="!errorIndexes.length"
333
+ :label="t('core.log.errors', { count: errorIndexes.length })" v-tooltip.top="t('core.log.firstError')"
334
+ @click="nextError" />
335
+ <QdButton icon="pi pi-align-left" rounded size="small" :text="!wrapping" :aria-label="t('core.log.wrap')"
336
+ :aria-pressed="wrapping" v-tooltip.top="t('core.log.wrap')" @click="toggleWrap" />
337
+ <QdButton icon="pi pi-angle-double-down" rounded size="small" :text="!following" :aria-label="t('core.log.follow')"
338
+ :aria-pressed="following" v-tooltip.top="t('core.log.follow')" @click="toggleFollow" />
339
+ <QdButton icon="pi pi-download" text rounded size="small" :disabled="!log.lines.value.length"
340
+ :aria-label="t('core.log.download')" v-tooltip.top="t('core.log.download')" @click="download" />
341
+ </div>
342
+
343
+ <div ref="viewport" class="qd-log-viewport" :style="{ height, '--qd-log-line-height': `${lineHeight}px` }" tabindex="0"
344
+ @scroll="onScroll" @keydown="onKeydown">
345
+ <div v-if="!view.length" class="qd-log-empty">{{ t('core.log.empty') }}</div>
346
+ <div v-else :style="wrapping ? undefined : { height: `${totalHeight}px`, position: 'relative' }">
347
+ <div :style="wrapping ? undefined : { transform: `translateY(${windowStart * lineHeight}px)` }">
348
+ <div v-for="(line, i) in windowRows" :key="line.n" class="qd-log-line" :data-index="windowStart + i"
349
+ :class="{
350
+ 'qd-log-line--error': isError(line),
351
+ 'qd-log-line--current': matchIndexes[currentMatch] === windowStart + i || errorIndexes[currentError] === windowStart + i,
352
+ }">
353
+ <span v-if="lineNumbers" class="qd-log-n">{{ line.n }}</span>
354
+ <span class="qd-log-text"><span v-for="(p, j) in pieces(line)" :key="j" :class="p.cls" :style="p.style">{{ p.text }}</span></span>
355
+ </div>
356
+ </div>
357
+ </div>
358
+ </div>
359
+
360
+ <button v-if="!following && unseen > 0" type="button" class="qd-log-resume" @click="resume">
361
+ <i class="pi pi-arrow-down" /> {{ t('core.log.newLines', { count: unseen }) }}
362
+ </button>
363
+ </div>
364
+ </template>
@@ -0,0 +1,236 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * PayloadDiff - compare two payloads, "expected" against "captured" (#4112)
4
+ *
5
+ * Usage:
6
+ * <PayloadDiff :left="expected" :right="capture.body" content-type="application/xml" />
7
+ * <PayloadDiff :left="feedA" :right="feedB" format="csv" csv-key="sku" />
8
+ *
9
+ * Both sides are normalised first (JSON with sorted keys, XML indented, form
10
+ * fields sorted), then compared line by line: formatting noise disappears,
11
+ * so what is left reads as a difference of content. A CSV with `csv-key` is
12
+ * compared row by row on that column, cell by cell by header name.
13
+ */
14
+ import { computed, ref } from 'vue'
15
+ import QdButton from '../base/QdButton.vue'
16
+ import { useI18n } from '../../i18n/useI18n'
17
+ import {
18
+ bodyText,
19
+ detectFormat,
20
+ diffCsv,
21
+ diffLines,
22
+ maskCsv,
23
+ normalizeLines,
24
+ parseCsv,
25
+ type DiffOp,
26
+ type PayloadBody,
27
+ type PayloadFormat,
28
+ type PayloadMask,
29
+ } from '../../utils/payload'
30
+
31
+ interface Props {
32
+ left?: PayloadBody
33
+ right?: PayloadBody
34
+ labels?: [string, string] | null
35
+ mode?: 'split' | 'inline'
36
+ format?: PayloadFormat | null
37
+ contentType?: string | null
38
+ /** CSV only: compare rows matched on this column. */
39
+ csvKey?: string | null
40
+ mask?: PayloadMask | null
41
+ /** Unchanged lines kept around each change; longer runs fold. */
42
+ context?: number
43
+ maxHeight?: string
44
+ }
45
+
46
+ const props = withDefaults(defineProps<Props>(), {
47
+ left: null,
48
+ right: null,
49
+ labels: null,
50
+ mode: 'split',
51
+ format: null,
52
+ contentType: null,
53
+ csvKey: null,
54
+ mask: null,
55
+ context: 3,
56
+ maxHeight: '32rem',
57
+ })
58
+
59
+ const { t } = useI18n()
60
+ const labels = computed(() => props.labels ?? [t('core.payload.expected'), t('core.payload.captured')])
61
+ const currentMode = ref(props.mode)
62
+
63
+ const format = computed<PayloadFormat>(() => props.format ?? detectFormat(props.left ?? props.right, props.contentType))
64
+
65
+ // --- Keyed CSV ---
66
+
67
+ const csvDiff = computed(() => {
68
+ if (format.value !== 'csv' || !props.csvKey) return null
69
+ const l = maskCsv(parseCsv(bodyText(props.left)).rows, props.mask, true)
70
+ const r = maskCsv(parseCsv(bodyText(props.right)).rows, props.mask, true)
71
+ return diffCsv(l, r, props.csvKey)
72
+ })
73
+ const csvCounts = computed(() => {
74
+ const out = { added: 0, removed: 0, changed: 0, same: 0 }
75
+ for (const row of csvDiff.value?.rows ?? []) out[row.status]++
76
+ return out
77
+ })
78
+ const showSame = ref(false)
79
+
80
+ // --- Lines ---
81
+
82
+ const ops = computed<DiffOp[]>(() =>
83
+ csvDiff.value ? [] : diffLines(normalizeLines(props.left, format.value, props.mask), normalizeLines(props.right, format.value, props.mask))
84
+ )
85
+ const counts = computed(() => ({
86
+ added: ops.value.filter((o) => o.type === 'insert').length,
87
+ removed: ops.value.filter((o) => o.type === 'delete').length,
88
+ }))
89
+ const identical = computed(() => (csvDiff.value ? csvCounts.value.added + csvCounts.value.removed + csvCounts.value.changed === 0 : counts.value.added + counts.value.removed === 0))
90
+
91
+ interface Hunk {
92
+ type: 'ops' | 'fold'
93
+ key: string
94
+ ops: DiffOp[]
95
+ }
96
+
97
+ const unfolded = ref(new Set<string>())
98
+
99
+ /** Long runs of unchanged lines fold, keeping `context` lines around each change. */
100
+ const hunks = computed<Hunk[]>(() => {
101
+ const list = ops.value
102
+ const keep = new Array<boolean>(list.length).fill(false)
103
+ list.forEach((op, i) => {
104
+ if (op.type === 'equal') return
105
+ for (let j = Math.max(0, i - props.context); j <= Math.min(list.length - 1, i + props.context); j++) keep[j] = true
106
+ })
107
+ const out: Hunk[] = []
108
+ let i = 0
109
+ while (i < list.length) {
110
+ const start = i
111
+ const kept = keep[i] || list.length <= props.context * 2 + 1
112
+ while (i < list.length && (keep[i] || list.length <= props.context * 2 + 1) === kept) i++
113
+ const slice = list.slice(start, i)
114
+ const key = `${start}`
115
+ if (kept || slice.length < 4 || unfolded.value.has(key)) out.push({ type: 'ops', key, ops: slice })
116
+ else out.push({ type: 'fold', key, ops: slice })
117
+ }
118
+ return out
119
+ })
120
+
121
+ function unfold(key: string): void {
122
+ unfolded.value = new Set([...unfolded.value, key])
123
+ }
124
+
125
+ interface SplitRow {
126
+ left?: DiffOp
127
+ right?: DiffOp
128
+ }
129
+
130
+ /** Side by side: a run of deletions faces the run of insertions that follows it. */
131
+ function splitRows(list: DiffOp[]): SplitRow[] {
132
+ const rows: SplitRow[] = []
133
+ let i = 0
134
+ while (i < list.length) {
135
+ const op = list[i]!
136
+ if (op.type === 'equal') {
137
+ rows.push({ left: op, right: op })
138
+ i++
139
+ continue
140
+ }
141
+ const dels: DiffOp[] = []
142
+ const ins: DiffOp[] = []
143
+ while (i < list.length && list[i]!.type === 'delete') dels.push(list[i++]!)
144
+ while (i < list.length && list[i]!.type === 'insert') ins.push(list[i++]!)
145
+ for (let k = 0; k < Math.max(dels.length, ins.length); k++) rows.push({ left: dels[k], right: ins[k] })
146
+ }
147
+ return rows
148
+ }
149
+ </script>
150
+
151
+ <template>
152
+ <div class="qd-payload qd-payload-diff">
153
+ <div class="qd-payload-toolbar">
154
+ <span class="qd-payload-format">{{ format.toUpperCase() }}</span>
155
+ <template v-if="csvDiff">
156
+ <span class="pl-count pl-count--add">+{{ csvCounts.added }}</span>
157
+ <span class="pl-count pl-count--del">−{{ csvCounts.removed }}</span>
158
+ <span class="pl-count pl-count--chg">~{{ csvCounts.changed }}</span>
159
+ <label class="qd-payload-check"><input v-model="showSame" type="checkbox" /> {{ t('core.payload.showSame', { count: csvCounts.same }) }}</label>
160
+ </template>
161
+ <template v-else>
162
+ <span class="pl-count pl-count--add">+{{ counts.added }}</span>
163
+ <span class="pl-count pl-count--del">−{{ counts.removed }}</span>
164
+ </template>
165
+ <span class="qd-payload-spacer" />
166
+ <QdButton v-if="!csvDiff" size="small" text :icon="currentMode === 'split' ? 'pi pi-bars' : 'pi pi-pause'"
167
+ :label="currentMode === 'split' ? t('core.payload.inline') : t('core.payload.split')"
168
+ @click="currentMode = currentMode === 'split' ? 'inline' : 'split'" />
169
+ </div>
170
+
171
+ <div v-if="identical" class="qd-payload-identical"><i class="pi pi-check" /> {{ t('core.payload.identical') }}</div>
172
+
173
+ <div class="qd-payload-body" :style="{ maxHeight }">
174
+ <table v-if="csvDiff" class="qd-payload-table pl-csv-diff">
175
+ <thead>
176
+ <tr>
177
+ <th class="pl-n" />
178
+ <th v-for="h in csvDiff.header" :key="h">{{ h }}</th>
179
+ </tr>
180
+ </thead>
181
+ <tbody>
182
+ <template v-for="row in csvDiff.rows" :key="row.key">
183
+ <tr v-if="row.status !== 'same' || showSame" :class="`pl-row--${row.status}`">
184
+ <td class="pl-n">{{ row.status === 'added' ? '+' : row.status === 'removed' ? '−' : row.status === 'changed' ? '~' : '' }}</td>
185
+ <td v-for="(h, c) in csvDiff.header" :key="h" :class="{ 'pl-cell--changed': row.changed.includes(c) }">
186
+ <template v-if="row.changed.includes(c)">
187
+ <del>{{ row.left?.[c] }}</del> <ins>{{ row.right?.[c] }}</ins>
188
+ </template>
189
+ <template v-else>{{ (row.right ?? row.left)?.[c] }}</template>
190
+ </td>
191
+ </tr>
192
+ </template>
193
+ </tbody>
194
+ </table>
195
+
196
+ <table v-else-if="currentMode === 'split'" class="pl-diff pl-diff--split">
197
+ <thead>
198
+ <tr>
199
+ <th colspan="2">{{ labels[0] }}</th>
200
+ <th colspan="2">{{ labels[1] }}</th>
201
+ </tr>
202
+ </thead>
203
+ <tbody>
204
+ <template v-for="hunk in hunks" :key="hunk.key">
205
+ <tr v-if="hunk.type === 'fold'" class="pl-fold">
206
+ <td colspan="4"><button type="button" @click="unfold(hunk.key)">{{ t('core.payload.unchanged', { count: hunk.ops.length }) }}</button></td>
207
+ </tr>
208
+ <tr v-for="(row, i) in (hunk.type === 'ops' ? splitRows(hunk.ops) : [])" v-else :key="`${hunk.key}:${i}`">
209
+ <td class="pl-n">{{ row.left?.left !== undefined ? row.left.left + 1 : '' }}</td>
210
+ <td :class="row.left ? (row.left.type === 'delete' ? 'pl-del' : '') : 'pl-empty'">{{ row.left?.text }}</td>
211
+ <td class="pl-n">{{ row.right?.right !== undefined ? row.right.right + 1 : '' }}</td>
212
+ <td :class="row.right ? (row.right.type === 'insert' ? 'pl-ins' : '') : 'pl-empty'">{{ row.right?.text }}</td>
213
+ </tr>
214
+ </template>
215
+ </tbody>
216
+ </table>
217
+
218
+ <table v-else class="pl-diff pl-diff--inline">
219
+ <tbody>
220
+ <template v-for="hunk in hunks" :key="hunk.key">
221
+ <tr v-if="hunk.type === 'fold'" class="pl-fold">
222
+ <td colspan="4"><button type="button" @click="unfold(hunk.key)">{{ t('core.payload.unchanged', { count: hunk.ops.length }) }}</button></td>
223
+ </tr>
224
+ <tr v-for="(op, i) in (hunk.type === 'ops' ? hunk.ops : [])" v-else :key="`${hunk.key}:${i}`"
225
+ :class="op.type === 'delete' ? 'pl-del' : op.type === 'insert' ? 'pl-ins' : ''">
226
+ <td class="pl-n">{{ op.left !== undefined ? op.left + 1 : '' }}</td>
227
+ <td class="pl-n">{{ op.right !== undefined ? op.right + 1 : '' }}</td>
228
+ <td class="pl-sign">{{ op.type === 'delete' ? '−' : op.type === 'insert' ? '+' : '' }}</td>
229
+ <td>{{ op.text }}</td>
230
+ </tr>
231
+ </template>
232
+ </tbody>
233
+ </table>
234
+ </div>
235
+ </div>
236
+ </template>