@pgcorp/ui-kit 0.8.1 → 0.10.0

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 (113) hide show
  1. package/README.md +332 -9
  2. package/docs/getting-started.md +135 -0
  3. package/docs/public-api.md +53 -4
  4. package/docs/rich-document.md +216 -0
  5. package/docs/scroll-viewport.md +167 -0
  6. package/docs/theming.md +17 -0
  7. package/package.json +108 -50
  8. package/src/components/layout/SAppShell.vue +3 -1
  9. package/src/components/layout/SDockRegion.vue +5 -3
  10. package/src/components/layout/SResizeHandle.vue +45 -5
  11. package/src/components/layout/SWorkbenchPanelShell.vue +4 -2
  12. package/src/components/shared/_internal/SDialogSurface.css +23 -0
  13. package/src/components/shared/_internal/SDialogSurface.vue +9 -2
  14. package/src/components/shared/_internal/SToastItem.css +4 -0
  15. package/src/components/shared/_internal/SToastItem.vue +44 -8
  16. package/src/components/shared/_internal/STreeNode.vue +24 -11
  17. package/src/components/shared/_internal/dialogSurface.ts +7 -1
  18. package/src/components/shared/complex/SKanbanLane.vue +4 -2
  19. package/src/components/shared/containers/SCloudChromeRow.vue +3 -2
  20. package/src/components/shared/containers/SDrawer.css +11 -2
  21. package/src/components/shared/containers/SDrawer.vue +48 -9
  22. package/src/components/shared/containers/SModal.vue +5 -2
  23. package/src/components/shared/containers/SPanel.vue +4 -2
  24. package/src/components/shared/containers/SPopover.vue +7 -3
  25. package/src/components/shared/containers/SUiKitLocaleProvider.vue +57 -0
  26. package/src/components/shared/controls/SButton.css +14 -10
  27. package/src/components/shared/controls/SCombobox.vue +8 -6
  28. package/src/components/shared/controls/SComboboxTrigger.css +7 -4
  29. package/src/components/shared/controls/SComboboxTrigger.vue +5 -2
  30. package/src/components/shared/controls/SContextToggleButton.vue +6 -4
  31. package/src/components/shared/controls/SDragHandle.vue +8 -4
  32. package/src/components/shared/controls/SDropdownMenu.vue +7 -4
  33. package/src/components/shared/controls/SInputText.css +5 -4
  34. package/src/components/shared/controls/SListbox.vue +4 -2
  35. package/src/components/shared/controls/SSelect.vue +4 -2
  36. package/src/components/shared/controls/STextarea.ts +13 -0
  37. package/src/components/shared/controls/STextarea.vue +391 -37
  38. package/src/components/shared/controls/_internal/SInlineTokenSurface.vue +4 -2
  39. package/src/components/shared/data-display/SChart.vue +7 -5
  40. package/src/components/shared/data-display/SChip.vue +3 -1
  41. package/src/components/shared/data-display/SCodeBlock.css +4 -0
  42. package/src/components/shared/data-display/SCodeBlock.vue +46 -22
  43. package/src/components/shared/data-display/SCodeEditor.css +17 -0
  44. package/src/components/shared/data-display/SCodeEditor.vue +173 -8
  45. package/src/components/shared/data-display/SCodeSearchPanel.vue +48 -33
  46. package/src/components/shared/data-display/SCodeWorkspaceEditor.css +13 -0
  47. package/src/components/shared/data-display/SCodeWorkspaceEditor.vue +355 -0
  48. package/src/components/shared/data-display/SControlledToastViewport.css +89 -0
  49. package/src/components/shared/data-display/SControlledToastViewport.vue +205 -0
  50. package/src/components/shared/data-display/SCopyField.vue +12 -10
  51. package/src/components/shared/data-display/SDiffViewer.css +122 -0
  52. package/src/components/shared/data-display/SDiffViewer.vue +499 -0
  53. package/src/components/shared/data-display/SDocBlock.vue +99 -71
  54. package/src/components/shared/data-display/SExpandableText.vue +10 -8
  55. package/src/components/shared/data-display/SJsonTree.vue +4 -2
  56. package/src/components/shared/data-display/SLinkedSystemsList.vue +4 -2
  57. package/src/components/shared/data-display/SMessage.vue +6 -6
  58. package/src/components/shared/data-display/SRichDocument.css +8 -0
  59. package/src/components/shared/data-display/SRichDocument.vue +65 -0
  60. package/src/components/shared/data-display/SScrollViewport.css +53 -0
  61. package/src/components/shared/data-display/SScrollViewport.vue +470 -0
  62. package/src/components/shared/data-display/STable.vue +38 -19
  63. package/src/components/shared/data-display/SToastContainer.vue +22 -17
  64. package/src/components/shared/data-display/SVirtualList.ts +33 -0
  65. package/src/components/shared/data-display/SVirtualList.vue +321 -36
  66. package/src/components/shared/data-display/_internal/SCodeTokenLine.css +72 -0
  67. package/src/components/shared/data-display/_internal/SCodeTokenLine.vue +79 -0
  68. package/src/components/shared/data-display/_internal/SRichBlockNode.css +85 -0
  69. package/src/components/shared/data-display/_internal/SRichBlockNode.vue +282 -0
  70. package/src/components/shared/data-display/_internal/SRichInlineNode.css +6 -0
  71. package/src/components/shared/data-display/_internal/SRichInlineNode.vue +76 -0
  72. package/src/components/shared/data-display/_internal/SRichMath.css +42 -0
  73. package/src/components/shared/data-display/_internal/SRichMath.vue +102 -0
  74. package/src/components/shared/data-display/codeEditor.ts +200 -0
  75. package/src/components/shared/data-display/codeWorkspace.ts +233 -0
  76. package/src/components/shared/data-display/diff.ts +541 -0
  77. package/src/components/shared/data-display/richDocument.ts +876 -0
  78. package/src/components/shared/data-display/scrollViewport.ts +47 -0
  79. package/src/components/shared/data-display/toast.ts +194 -0
  80. package/src/components/shared/database/SDataGrid.vue +7 -4
  81. package/src/components/shared/database/SSqlEditor.vue +39 -39
  82. package/src/components/shared/feedback/SAsyncState.vue +3 -1
  83. package/src/components/shared/graph/SGraphViewport.vue +11 -9
  84. package/src/components/shared/navigation/SBreadcrumbs.vue +8 -5
  85. package/src/components/shared/navigation/SCatalogNavigator.vue +26 -20
  86. package/src/components/shared/navigation/STabList.vue +10 -6
  87. package/src/components/shared/navigation/STabs.vue +15 -11
  88. package/src/components/shared/navigation/SWizardSteps.vue +4 -2
  89. package/src/components/shared/persona/SPersonaProjectPicker.vue +31 -27
  90. package/src/components/shared/persona/SPersonaRunWorkbench.vue +39 -33
  91. package/src/components/shared/persona/_internal/SPersonaRunConsole.vue +8 -6
  92. package/src/components/shared/persona/_internal/labels.ts +66 -57
  93. package/src/components/shared/persona/presentation.ts +140 -110
  94. package/src/composables/useFloatingPosition.ts +39 -2
  95. package/src/composables/useUiKitLocale.ts +17 -0
  96. package/src/internal/codeHighlight.ts +110 -0
  97. package/src/internal/diffEngine.ts +464 -0
  98. package/src/internal/diffRuntime.ts +189 -0
  99. package/src/internal/feedbackPresentation.ts +33 -14
  100. package/src/internal/localeContext.ts +22 -0
  101. package/src/internal/ownedAttrs.ts +8 -0
  102. package/src/internal/runtimeContract.ts +11 -9
  103. package/src/internal/semanticSizing.ts +13 -0
  104. package/src/locales/catalog.ts +634 -0
  105. package/src/locales/locale.ts +472 -0
  106. package/src/locales/messages.generated.ts +1826 -0
  107. package/src/locales/messages.ts +150 -0
  108. package/src/stores/useNotifierStore.ts +61 -44
  109. package/src/styles/tokens.css +23 -2
  110. package/src/theme.ts +59 -32
  111. package/src/workers/diff.worker.ts +73 -0
  112. package/src/workers/diffProtocol.ts +155 -0
  113. package/src/components/shared/data-display/SToastContainer.css +0 -53
@@ -0,0 +1,499 @@
1
+ <template>
2
+ <section
3
+ v-bind="ownedAttrs.bindings()"
4
+ class="s-diff-viewer"
5
+ :data-layout="validatedLayout"
6
+ :data-viewport="validatedViewport"
7
+ :data-state="status"
8
+ :aria-label="validatedAriaLabel"
9
+ :style="rootStyle"
10
+ >
11
+ <header class="s-diff-viewer__header">
12
+ <div class="s-diff-viewer__sources">
13
+ <span class="s-diff-viewer__source" data-side="before">{{ beforeLabel }}</span>
14
+ <span class="s-diff-viewer__direction" aria-hidden="true">→</span>
15
+ <span class="s-diff-viewer__source" data-side="after">{{ afterLabel }}</span>
16
+ </div>
17
+ <span v-if="result" class="s-diff-viewer__summary">
18
+ {{ t('editor.diffSummary', { added: result.summary.addedLineCount, removed: result.summary.removedLineCount }) }}
19
+ </span>
20
+ </header>
21
+
22
+ <div v-if="validatedLayout === 'split' && status === 'ready'" class="s-diff-viewer__split-header" aria-hidden="true">
23
+ <span>{{ beforeLabel }}</span>
24
+ <span>{{ afterLabel }}</span>
25
+ </div>
26
+
27
+ <SAsyncState :state="asyncState" presentation="block">
28
+ <template #error="{ message }">
29
+ <div class="s-diff-viewer__error">
30
+ <SMessage severity="danger" size="sm">{{ message }}</SMessage>
31
+ <SButton
32
+ :label="t('actions.retry')"
33
+ appearance="soft"
34
+ severity="secondary"
35
+ size="sm"
36
+ @click="retry"
37
+ />
38
+ </div>
39
+ </template>
40
+
41
+ <div class="s-diff-viewer__viewport">
42
+ <SVirtualList
43
+ :items="renderRows"
44
+ :item-size="DIFF_ROW_HEIGHT"
45
+ :overscan="8"
46
+ :height="null"
47
+ :item-key="rowKey"
48
+ :item-label="rowLabel"
49
+ :accessible-label="validatedAriaLabel"
50
+ >
51
+ <template #default="{ item }">
52
+ <div v-if="item.kind === 'gap'" class="s-diff-viewer__gap-row">
53
+ <SButton
54
+ :label="gapButtonLabel(item)"
55
+ :disclosure="{ expanded: item.expanded, controls: gapControlsId(item.gap.id), iconPlacement: 'leading' }"
56
+ appearance="text"
57
+ severity="secondary"
58
+ size="xs"
59
+ density="compact"
60
+ width="full"
61
+ @click="toggleGap(item.gap.id)"
62
+ />
63
+ </div>
64
+
65
+ <div
66
+ v-else-if="item.kind === 'unified-line'"
67
+ class="s-diff-viewer__unified-row"
68
+ :data-kind="item.line.kind"
69
+ :id="item.gapId ? gapControlsId(item.gapId) : undefined"
70
+ >
71
+ <span class="s-diff-viewer__marker" aria-hidden="true">{{ unifiedMarker(item.line.kind) }}</span>
72
+ <span class="s-diff-viewer__line-number" aria-hidden="true">{{ item.line.before?.lineNumber ?? '' }}</span>
73
+ <span class="s-diff-viewer__line-number" aria-hidden="true">{{ item.line.after?.lineNumber ?? '' }}</span>
74
+ <span class="s-diff-viewer__code-cell">
75
+ <SCodeTokenLine
76
+ :text="unifiedSide(item.line).text"
77
+ :language="unifiedLanguage(item.line)"
78
+ />
79
+ <span
80
+ v-if="unifiedSide(item.line).lineBreak === 'none'"
81
+ class="s-diff-viewer__missing-break"
82
+ :aria-label="t('editor.diffMissingLineBreak')"
83
+ >∅</span>
84
+ </span>
85
+ </div>
86
+
87
+ <div
88
+ v-else
89
+ class="s-diff-viewer__split-row"
90
+ :id="item.gapId ? gapControlsId(item.gapId) : undefined"
91
+ >
92
+ <div class="s-diff-viewer__split-cell" :data-kind="item.beforeKind">
93
+ <span class="s-diff-viewer__line-number" aria-hidden="true">{{ item.before?.lineNumber ?? '' }}</span>
94
+ <span v-if="item.before" class="s-diff-viewer__code-cell">
95
+ <SCodeTokenLine :text="item.before.text" :language="validatedBefore.language" wrap />
96
+ <span
97
+ v-if="item.before.lineBreak === 'none'"
98
+ class="s-diff-viewer__missing-break"
99
+ :aria-label="t('editor.diffMissingLineBreak')"
100
+ >∅</span>
101
+ </span>
102
+ </div>
103
+ <div class="s-diff-viewer__split-cell" :data-kind="item.afterKind">
104
+ <span class="s-diff-viewer__line-number" aria-hidden="true">{{ item.after?.lineNumber ?? '' }}</span>
105
+ <span v-if="item.after" class="s-diff-viewer__code-cell">
106
+ <SCodeTokenLine :text="item.after.text" :language="validatedAfter.language" wrap />
107
+ <span
108
+ v-if="item.after.lineBreak === 'none'"
109
+ class="s-diff-viewer__missing-break"
110
+ :aria-label="t('editor.diffMissingLineBreak')"
111
+ >∅</span>
112
+ </span>
113
+ </div>
114
+ </div>
115
+ </template>
116
+ </SVirtualList>
117
+ </div>
118
+ </SAsyncState>
119
+ </section>
120
+ </template>
121
+
122
+ <script setup lang="ts">
123
+ import {
124
+ computed,
125
+ onBeforeUnmount,
126
+ ref,
127
+ watch,
128
+ type CSSProperties,
129
+ } from 'vue'
130
+
131
+ import { useUiKitLocale } from '../../../composables/useUiKitLocale'
132
+ import {
133
+ createDiffRuntime,
134
+ SDiffComputationCancelledError,
135
+ } from '../../../internal/diffRuntime'
136
+ import { useOwnedAttrs } from '../../../internal/ownedAttrs'
137
+ import {
138
+ validateBoundedInteger,
139
+ validateExactString,
140
+ validateNonEmptyString,
141
+ } from '../../../internal/runtimeContract'
142
+ import SButton from '../controls/SButton.vue'
143
+ import SAsyncState, { type SAsyncStateContract } from '../feedback/SAsyncState.vue'
144
+ import SMessage from './SMessage.vue'
145
+ import SCodeTokenLine from './_internal/SCodeTokenLine.vue'
146
+ import SVirtualList from './SVirtualList.vue'
147
+ import {
148
+ S_DIFF_MAX_CONTEXT_LINES,
149
+ validateDiffSource,
150
+ type SDiffGap,
151
+ type SDiffLanguage,
152
+ type SDiffLine,
153
+ type SDiffLineKind,
154
+ type SDiffLineSide,
155
+ type SDiffResult,
156
+ type SDiffSource,
157
+ } from './diff'
158
+
159
+ defineOptions({ inheritAttrs: false })
160
+
161
+ /** Presentation of canonical line operations. Split mode pairs adjacent remove/add runs by position. */
162
+ export type SDiffViewerLayout = 'unified' | 'split'
163
+ /** Semantic viewport sizes shared with code surfaces; fill consumes the containing block. */
164
+ export type SDiffViewerViewport = 'content' | 'compact' | 'standard' | 'expanded' | 'fill'
165
+
166
+ /** Public exact-text diff contract. Inputs are validated without newline or payload normalization. */
167
+ export interface Props {
168
+ /** Previous exact source snapshot. */
169
+ before: SDiffSource
170
+ /** Current exact source snapshot. */
171
+ after: SDiffSource
172
+ layout?: SDiffViewerLayout
173
+ /** Unchanged lines retained around each changed hunk. */
174
+ contextLines?: number
175
+ /** Optional controlled set of expanded unchanged gap identities. */
176
+ expandedGapIds?: readonly string[]
177
+ viewport?: SDiffViewerViewport
178
+ /** Accessible name for the diff region and its virtualized line collection. */
179
+ ariaLabel: string
180
+ }
181
+
182
+ type UnifiedLineRow = {
183
+ readonly kind: 'unified-line'
184
+ readonly key: string
185
+ readonly line: SDiffLine
186
+ readonly gapId?: string
187
+ }
188
+
189
+ type SplitLineRow = {
190
+ readonly kind: 'split-line'
191
+ readonly key: string
192
+ readonly before?: SDiffLineSide
193
+ readonly after?: SDiffLineSide
194
+ readonly beforeKind: SDiffLineKind | 'empty'
195
+ readonly afterKind: SDiffLineKind | 'empty'
196
+ readonly gapId?: string
197
+ }
198
+
199
+ type GapRow = {
200
+ readonly kind: 'gap'
201
+ readonly key: string
202
+ readonly gap: SDiffGap
203
+ readonly expanded: boolean
204
+ }
205
+
206
+ type DiffRenderRow = UnifiedLineRow | SplitLineRow | GapRow
207
+
208
+ const DIFF_ROW_HEIGHT = 32
209
+ const props = withDefaults(defineProps<Props>(), {
210
+ layout: 'unified',
211
+ contextLines: 3,
212
+ expandedGapIds: undefined,
213
+ viewport: 'standard',
214
+ })
215
+ const emit = defineEmits<{
216
+ (event: 'update:expandedGapIds', value: readonly string[]): void
217
+ (event: 'retry'): void
218
+ (event: 'computed', result: SDiffResult): void
219
+ }>()
220
+ const { t } = useUiKitLocale()
221
+ const ownedAttrs = useOwnedAttrs({ component: 'SDiffViewer', owner: 'diff viewer root' })
222
+ const runtime = createDiffRuntime()
223
+ const result = ref<SDiffResult | null>(null)
224
+ const status = ref<'loading' | 'ready' | 'empty' | 'error'>('loading')
225
+ const errorMessage = ref('')
226
+ const uncontrolledExpandedGapIds = ref<readonly string[]>(Object.freeze([]))
227
+ let generation = 0
228
+ let abortController: AbortController | null = null
229
+
230
+ const validatedBefore = computed(() => validateDiffSource('SDiffViewer', 'before', props.before))
231
+ const validatedAfter = computed(() => validateDiffSource('SDiffViewer', 'after', props.after))
232
+ const validatedLayout = computed(() => validateExactString(
233
+ 'SDiffViewer',
234
+ 'layout',
235
+ props.layout,
236
+ ['unified', 'split'] as const,
237
+ ))
238
+ const validatedViewport = computed(() => validateExactString(
239
+ 'SDiffViewer',
240
+ 'viewport',
241
+ props.viewport,
242
+ ['content', 'compact', 'standard', 'expanded', 'fill'] as const,
243
+ ))
244
+ const validatedContextLines = computed(() => validateBoundedInteger(
245
+ 'SDiffViewer',
246
+ 'contextLines',
247
+ props.contextLines,
248
+ { minimum: 0, maximum: S_DIFF_MAX_CONTEXT_LINES },
249
+ ))
250
+ const validatedAriaLabel = computed(() => validateNonEmptyString('SDiffViewer', 'ariaLabel', props.ariaLabel))
251
+ const beforeLabel = computed(() => validatedBefore.value.label ?? t('editor.diffBefore'))
252
+ const afterLabel = computed(() => validatedAfter.value.label ?? t('editor.diffAfter'))
253
+
254
+ function validateExpandedGapIds(value: unknown, currentResult: SDiffResult | null): readonly string[] {
255
+ if (!Array.isArray(value)) throw new TypeError('SDiffViewer: expandedGapIds must be an array.')
256
+ const gapIds = new Set(currentResult?.gaps.map((gap) => gap.id) ?? [])
257
+ const seen = new Set<string>()
258
+ value.forEach((candidate, index) => {
259
+ const id = validateNonEmptyString('SDiffViewer', `expandedGapIds[${index}]`, candidate)
260
+ if (seen.has(id)) throw new TypeError(`SDiffViewer: expandedGapIds contains duplicate ${JSON.stringify(id)}.`)
261
+ if (currentResult !== null && !gapIds.has(id)) {
262
+ throw new RangeError(`SDiffViewer: expandedGapIds[${index}] ${JSON.stringify(id)} is stale.`)
263
+ }
264
+ seen.add(id)
265
+ })
266
+ return Object.freeze([...seen])
267
+ }
268
+
269
+ const expandedGapIds = computed(() => props.expandedGapIds === undefined
270
+ ? uncontrolledExpandedGapIds.value
271
+ : validateExpandedGapIds(props.expandedGapIds, result.value))
272
+ const expandedGapSet = computed(() => new Set(expandedGapIds.value))
273
+
274
+ const asyncState = computed<SAsyncStateContract>(() => {
275
+ if (status.value === 'loading') return { status: 'loading', message: t('editor.diffComputing') }
276
+ if (status.value === 'empty') return { status: 'empty', message: t('editor.diffNoChanges') }
277
+ if (status.value === 'error') return { status: 'error', message: errorMessage.value }
278
+ return { status: 'ready' }
279
+ })
280
+
281
+ function alignSplitLines(lines: readonly SDiffLine[]): SplitLineRow[] {
282
+ const rows: SplitLineRow[] = []
283
+ let index = 0
284
+ while (index < lines.length) {
285
+ const line = lines[index]
286
+ if (line.kind === 'unchanged') {
287
+ rows.push({
288
+ kind: 'split-line',
289
+ key: `split:${line.id}`,
290
+ before: line.before,
291
+ after: line.after,
292
+ beforeKind: 'unchanged',
293
+ afterKind: 'unchanged',
294
+ })
295
+ index += 1
296
+ continue
297
+ }
298
+ const changedBlock: SDiffLine[] = []
299
+ while (index < lines.length && lines[index].kind !== 'unchanged') {
300
+ changedBlock.push(lines[index])
301
+ index += 1
302
+ }
303
+ const removed = changedBlock.filter((changedLine) => changedLine.kind === 'removed')
304
+ const added = changedBlock.filter((changedLine) => changedLine.kind === 'added')
305
+ const pairCount = Math.max(removed.length, added.length)
306
+ for (let pairIndex = 0; pairIndex < pairCount; pairIndex += 1) {
307
+ const beforeLine = removed[pairIndex]
308
+ const afterLine = added[pairIndex]
309
+ rows.push({
310
+ kind: 'split-line',
311
+ key: `split:${beforeLine?.id ?? '-'}:${afterLine?.id ?? '-'}`,
312
+ ...(beforeLine?.before === undefined ? {} : { before: beforeLine.before }),
313
+ ...(afterLine?.after === undefined ? {} : { after: afterLine.after }),
314
+ beforeKind: beforeLine === undefined ? 'empty' : 'removed',
315
+ afterKind: afterLine === undefined ? 'empty' : 'added',
316
+ })
317
+ }
318
+ }
319
+ return rows
320
+ }
321
+
322
+ function lineRows(lines: readonly SDiffLine[], gapId?: string): Array<UnifiedLineRow | SplitLineRow> {
323
+ const rows: Array<UnifiedLineRow | SplitLineRow> = validatedLayout.value === 'split'
324
+ ? alignSplitLines(lines)
325
+ : lines.map((line) => ({
326
+ kind: 'unified-line',
327
+ key: `unified:${line.id}`,
328
+ line,
329
+ }))
330
+ if (gapId === undefined || rows.length === 0) return rows
331
+ const [firstRow, ...remainingRows] = rows
332
+ return [{ ...firstRow, gapId }, ...remainingRows]
333
+ }
334
+
335
+ const renderRows = computed<readonly DiffRenderRow[]>(() => {
336
+ const current = result.value
337
+ if (current === null || current.summary.changedLineCount === 0) return Object.freeze([])
338
+ const segments = [
339
+ ...current.hunks.map((hunk) => ({ kind: 'hunk' as const, value: hunk })),
340
+ ...current.gaps.map((gap) => ({ kind: 'gap' as const, value: gap })),
341
+ ].sort((left, right) => left.value.lineStart - right.value.lineStart)
342
+ const rows: DiffRenderRow[] = []
343
+ for (const segment of segments) {
344
+ if (segment.kind === 'hunk') {
345
+ rows.push(...lineRows(current.lines.slice(segment.value.lineStart, segment.value.lineEnd)))
346
+ continue
347
+ }
348
+ const expanded = expandedGapSet.value.has(segment.value.id)
349
+ rows.push({
350
+ kind: 'gap',
351
+ key: `gap-control:${segment.value.id}`,
352
+ gap: segment.value,
353
+ expanded,
354
+ })
355
+ if (expanded) {
356
+ rows.push(...lineRows(
357
+ current.lines.slice(segment.value.lineStart, segment.value.lineEnd),
358
+ segment.value.id,
359
+ ))
360
+ }
361
+ }
362
+ return Object.freeze(rows)
363
+ })
364
+
365
+ function estimateCodeColumns(text: string): number {
366
+ let columns = 0
367
+ for (const character of text) {
368
+ if (character === '\t') {
369
+ columns += 2 - (columns % 2)
370
+ continue
371
+ }
372
+ if (/\p{Mark}/u.test(character)) continue
373
+ columns += /^[\u0000-\u007f]$/u.test(character) ? 1 : 2
374
+ }
375
+ return columns
376
+ }
377
+
378
+ const unifiedCodeColumns = computed(() => {
379
+ let maximum = 1
380
+ for (const line of result.value?.lines ?? []) {
381
+ if (line.before !== undefined) maximum = Math.max(maximum, estimateCodeColumns(line.before.text))
382
+ if (line.after !== undefined) maximum = Math.max(maximum, estimateCodeColumns(line.after.text))
383
+ }
384
+ return maximum
385
+ })
386
+
387
+ const rootStyle = computed<CSSProperties>(() => ({
388
+ '--s-diff-content-row-count': String(Math.max(3, Math.min(14, renderRows.value.length + 1))),
389
+ '--s-diff-unified-code-inline-size': `${unifiedCodeColumns.value}ch`,
390
+ }))
391
+
392
+ function rowKey(row: DiffRenderRow): string {
393
+ return row.key
394
+ }
395
+
396
+ function describeLine(kind: SDiffLineKind, before: SDiffLineSide | undefined, after: SDiffLineSide | undefined): string {
397
+ if (kind === 'removed') return t('editor.diffRemovedLine', { line: before?.lineNumber ?? '' })
398
+ if (kind === 'added') return t('editor.diffAddedLine', { line: after?.lineNumber ?? '' })
399
+ return t('editor.diffUnchangedLine', {
400
+ before: before?.lineNumber ?? '',
401
+ after: after?.lineNumber ?? '',
402
+ })
403
+ }
404
+
405
+ function rowLabel(row: DiffRenderRow): string {
406
+ if (row.kind === 'gap') return gapButtonLabel(row)
407
+ if (row.kind === 'unified-line') return describeLine(row.line.kind, row.line.before, row.line.after)
408
+ return [
409
+ row.before === undefined ? '' : describeLine(row.beforeKind as SDiffLineKind, row.before, undefined),
410
+ row.after === undefined ? '' : describeLine(row.afterKind as SDiffLineKind, undefined, row.after),
411
+ ].filter(Boolean).join('; ')
412
+ }
413
+
414
+ function gapButtonLabel(row: GapRow): string {
415
+ return row.expanded
416
+ ? t('editor.diffCollapseGap', { count: row.gap.lineCount })
417
+ : t('editor.diffExpandGap', { count: row.gap.lineCount })
418
+ }
419
+
420
+ function gapControlsId(gapId: string): string {
421
+ return `s-diff-${gapId.replace(/[^a-zA-Z0-9_-]/gu, '-')}`
422
+ }
423
+
424
+ function toggleGap(gapId: string): void {
425
+ const next = new Set(expandedGapIds.value)
426
+ if (next.has(gapId)) next.delete(gapId)
427
+ else next.add(gapId)
428
+ const value = Object.freeze([...next])
429
+ if (props.expandedGapIds === undefined) uncontrolledExpandedGapIds.value = value
430
+ emit('update:expandedGapIds', value)
431
+ }
432
+
433
+ function unifiedMarker(kind: SDiffLineKind): string {
434
+ return kind === 'removed' ? '−' : kind === 'added' ? '+' : ' '
435
+ }
436
+
437
+ function unifiedSide(line: SDiffLine): SDiffLineSide {
438
+ const side = line.kind === 'removed' ? line.before : line.after
439
+ if (side === undefined) throw new Error(`SDiffViewer: ${line.id} has no renderable side.`)
440
+ return side
441
+ }
442
+
443
+ function unifiedLanguage(line: SDiffLine): SDiffLanguage | undefined {
444
+ return line.kind === 'removed' ? validatedBefore.value.language : validatedAfter.value.language
445
+ }
446
+
447
+ async function computeCurrentDiff(): Promise<void> {
448
+ const currentGeneration = generation += 1
449
+ abortController?.abort()
450
+ abortController = new AbortController()
451
+ result.value = null
452
+ status.value = 'loading'
453
+ errorMessage.value = ''
454
+ try {
455
+ const computedResult = await runtime.compute(
456
+ validatedBefore.value,
457
+ validatedAfter.value,
458
+ { contextLines: validatedContextLines.value },
459
+ abortController.signal,
460
+ )
461
+ if (currentGeneration !== generation) return
462
+ if (props.expandedGapIds !== undefined) {
463
+ validateExpandedGapIds(props.expandedGapIds, computedResult)
464
+ }
465
+ result.value = computedResult
466
+ if (props.expandedGapIds === undefined) {
467
+ const activeGapIds = new Set(computedResult.gaps.map((gap) => gap.id))
468
+ uncontrolledExpandedGapIds.value = Object.freeze(
469
+ uncontrolledExpandedGapIds.value.filter((gapId) => activeGapIds.has(gapId)),
470
+ )
471
+ }
472
+ status.value = computedResult.summary.changedLineCount === 0 ? 'empty' : 'ready'
473
+ emit('computed', computedResult)
474
+ } catch (cause) {
475
+ if (currentGeneration !== generation || cause instanceof SDiffComputationCancelledError) return
476
+ status.value = 'error'
477
+ errorMessage.value = cause instanceof Error ? cause.message : String(cause)
478
+ }
479
+ }
480
+
481
+ function retry(): void {
482
+ emit('retry')
483
+ void computeCurrentDiff()
484
+ }
485
+
486
+ watch(
487
+ [validatedBefore, validatedAfter, validatedContextLines],
488
+ () => { void computeCurrentDiff() },
489
+ { immediate: true },
490
+ )
491
+
492
+ onBeforeUnmount(() => {
493
+ generation += 1
494
+ abortController?.abort()
495
+ runtime.dispose()
496
+ })
497
+ </script>
498
+
499
+ <style lang="postcss" src="./SDiffViewer.css" scoped></style>