@pgcorp/ui-kit 0.9.0 → 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 (105) hide show
  1. package/README.md +188 -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/package.json +103 -50
  7. package/src/components/layout/SAppShell.vue +3 -1
  8. package/src/components/layout/SDockRegion.vue +5 -3
  9. package/src/components/layout/SWorkbenchPanelShell.vue +4 -2
  10. package/src/components/shared/_internal/SDialogSurface.css +5 -0
  11. package/src/components/shared/_internal/SDialogSurface.vue +3 -1
  12. package/src/components/shared/_internal/SToastItem.css +4 -0
  13. package/src/components/shared/_internal/SToastItem.vue +44 -8
  14. package/src/components/shared/_internal/STreeNode.vue +24 -11
  15. package/src/components/shared/complex/SKanbanLane.vue +4 -2
  16. package/src/components/shared/containers/SCloudChromeRow.vue +3 -2
  17. package/src/components/shared/containers/SDrawer.vue +5 -2
  18. package/src/components/shared/containers/SModal.vue +5 -2
  19. package/src/components/shared/containers/SPanel.vue +4 -2
  20. package/src/components/shared/containers/SPopover.vue +7 -3
  21. package/src/components/shared/containers/SUiKitLocaleProvider.vue +57 -0
  22. package/src/components/shared/controls/SButton.css +14 -10
  23. package/src/components/shared/controls/SCombobox.vue +8 -6
  24. package/src/components/shared/controls/SComboboxTrigger.css +7 -4
  25. package/src/components/shared/controls/SComboboxTrigger.vue +5 -2
  26. package/src/components/shared/controls/SContextToggleButton.vue +6 -4
  27. package/src/components/shared/controls/SDragHandle.vue +8 -4
  28. package/src/components/shared/controls/SDropdownMenu.vue +7 -4
  29. package/src/components/shared/controls/SInputText.css +5 -4
  30. package/src/components/shared/controls/SListbox.vue +4 -2
  31. package/src/components/shared/controls/SSelect.vue +4 -2
  32. package/src/components/shared/controls/_internal/SInlineTokenSurface.vue +4 -2
  33. package/src/components/shared/data-display/SChart.vue +7 -5
  34. package/src/components/shared/data-display/SChip.vue +3 -1
  35. package/src/components/shared/data-display/SCodeBlock.css +4 -0
  36. package/src/components/shared/data-display/SCodeBlock.vue +42 -20
  37. package/src/components/shared/data-display/SCodeEditor.css +17 -0
  38. package/src/components/shared/data-display/SCodeEditor.vue +173 -8
  39. package/src/components/shared/data-display/SCodeSearchPanel.vue +48 -33
  40. package/src/components/shared/data-display/SCodeWorkspaceEditor.css +13 -0
  41. package/src/components/shared/data-display/SCodeWorkspaceEditor.vue +355 -0
  42. package/src/components/shared/data-display/SControlledToastViewport.css +89 -0
  43. package/src/components/shared/data-display/SControlledToastViewport.vue +205 -0
  44. package/src/components/shared/data-display/SCopyField.vue +12 -10
  45. package/src/components/shared/data-display/SDiffViewer.css +122 -0
  46. package/src/components/shared/data-display/SDiffViewer.vue +499 -0
  47. package/src/components/shared/data-display/SDocBlock.vue +99 -71
  48. package/src/components/shared/data-display/SExpandableText.vue +10 -8
  49. package/src/components/shared/data-display/SJsonTree.vue +4 -2
  50. package/src/components/shared/data-display/SLinkedSystemsList.vue +4 -2
  51. package/src/components/shared/data-display/SMessage.vue +6 -6
  52. package/src/components/shared/data-display/SRichDocument.css +8 -0
  53. package/src/components/shared/data-display/SRichDocument.vue +65 -0
  54. package/src/components/shared/data-display/SScrollViewport.css +53 -0
  55. package/src/components/shared/data-display/SScrollViewport.vue +470 -0
  56. package/src/components/shared/data-display/STable.vue +38 -19
  57. package/src/components/shared/data-display/SToastContainer.vue +22 -17
  58. package/src/components/shared/data-display/_internal/SCodeTokenLine.css +72 -0
  59. package/src/components/shared/data-display/_internal/SCodeTokenLine.vue +79 -0
  60. package/src/components/shared/data-display/_internal/SRichBlockNode.css +85 -0
  61. package/src/components/shared/data-display/_internal/SRichBlockNode.vue +282 -0
  62. package/src/components/shared/data-display/_internal/SRichInlineNode.css +6 -0
  63. package/src/components/shared/data-display/_internal/SRichInlineNode.vue +76 -0
  64. package/src/components/shared/data-display/_internal/SRichMath.css +42 -0
  65. package/src/components/shared/data-display/_internal/SRichMath.vue +102 -0
  66. package/src/components/shared/data-display/codeEditor.ts +200 -0
  67. package/src/components/shared/data-display/codeWorkspace.ts +233 -0
  68. package/src/components/shared/data-display/diff.ts +541 -0
  69. package/src/components/shared/data-display/richDocument.ts +876 -0
  70. package/src/components/shared/data-display/scrollViewport.ts +47 -0
  71. package/src/components/shared/data-display/toast.ts +194 -0
  72. package/src/components/shared/database/SDataGrid.vue +7 -4
  73. package/src/components/shared/database/SSqlEditor.vue +39 -39
  74. package/src/components/shared/feedback/SAsyncState.vue +3 -1
  75. package/src/components/shared/graph/SGraphViewport.vue +11 -9
  76. package/src/components/shared/navigation/SBreadcrumbs.vue +8 -5
  77. package/src/components/shared/navigation/SCatalogNavigator.vue +26 -20
  78. package/src/components/shared/navigation/STabList.vue +10 -6
  79. package/src/components/shared/navigation/STabs.vue +15 -11
  80. package/src/components/shared/navigation/SWizardSteps.vue +4 -2
  81. package/src/components/shared/persona/SPersonaProjectPicker.vue +31 -27
  82. package/src/components/shared/persona/SPersonaRunWorkbench.vue +39 -33
  83. package/src/components/shared/persona/_internal/SPersonaRunConsole.vue +8 -6
  84. package/src/components/shared/persona/_internal/labels.ts +66 -57
  85. package/src/components/shared/persona/presentation.ts +140 -110
  86. package/src/composables/useFloatingPosition.ts +39 -2
  87. package/src/composables/useUiKitLocale.ts +17 -0
  88. package/src/internal/codeHighlight.ts +110 -0
  89. package/src/internal/diffEngine.ts +464 -0
  90. package/src/internal/diffRuntime.ts +189 -0
  91. package/src/internal/feedbackPresentation.ts +33 -14
  92. package/src/internal/localeContext.ts +22 -0
  93. package/src/internal/ownedAttrs.ts +8 -0
  94. package/src/internal/runtimeContract.ts +11 -9
  95. package/src/internal/semanticSizing.ts +13 -0
  96. package/src/locales/catalog.ts +634 -0
  97. package/src/locales/locale.ts +472 -0
  98. package/src/locales/messages.generated.ts +1826 -0
  99. package/src/locales/messages.ts +150 -0
  100. package/src/stores/useNotifierStore.ts +61 -44
  101. package/src/styles/tokens.css +20 -2
  102. package/src/theme.ts +59 -32
  103. package/src/workers/diff.worker.ts +73 -0
  104. package/src/workers/diffProtocol.ts +155 -0
  105. package/src/components/shared/data-display/SToastContainer.css +0 -53
@@ -0,0 +1,541 @@
1
+ import type { CodeEditorLanguageContract } from '../../../internal/codeEditorContract'
2
+ import { computeDiffModel } from '../../../internal/diffEngine'
3
+ import {
4
+ validateBoundedInteger,
5
+ validateExactString,
6
+ validateNonEmptyString,
7
+ } from '../../../internal/runtimeContract'
8
+ import { validateCodeEditorLanguage } from '../../../internal/codeEditorContract'
9
+
10
+ export type SDiffSourceRevision = string | number
11
+ export type SDiffLanguage = CodeEditorLanguageContract
12
+
13
+ /** Exact text input. UI labels and revisions remain metadata and never participate in line equality. */
14
+ export interface SDiffSource {
15
+ readonly text: string
16
+ readonly label?: string
17
+ readonly revision?: SDiffSourceRevision
18
+ readonly language?: SDiffLanguage
19
+ }
20
+
21
+ export interface SDiffComputationOptions {
22
+ readonly contextLines?: number
23
+ }
24
+
25
+ export type SDiffLineBreak = 'lf' | 'crlf' | 'none'
26
+ export type SDiffLineKind = 'unchanged' | 'removed' | 'added'
27
+
28
+ export interface SDiffLineSide {
29
+ readonly lineNumber: number
30
+ readonly text: string
31
+ readonly lineBreak: SDiffLineBreak
32
+ }
33
+
34
+ /** One semantic line operation. Missing sides are omitted instead of encoded as null. */
35
+ export interface SDiffLine {
36
+ readonly id: string
37
+ readonly kind: SDiffLineKind
38
+ readonly before?: SDiffLineSide
39
+ readonly after?: SDiffLineSide
40
+ }
41
+
42
+ export interface SDiffHunk {
43
+ readonly id: string
44
+ readonly lineStart: number
45
+ readonly lineEnd: number
46
+ readonly beforeStart: number
47
+ readonly beforeLineCount: number
48
+ readonly afterStart: number
49
+ readonly afterLineCount: number
50
+ }
51
+
52
+ /** Hidden unchanged interval between hunk windows. Indices use the half-open result.lines domain. */
53
+ export interface SDiffGap {
54
+ readonly id: string
55
+ readonly lineStart: number
56
+ readonly lineEnd: number
57
+ readonly beforeStart: number
58
+ readonly afterStart: number
59
+ readonly lineCount: number
60
+ }
61
+
62
+ export interface SDiffSourceSnapshot {
63
+ readonly lineCount: number
64
+ readonly label?: string
65
+ readonly revision?: SDiffSourceRevision
66
+ readonly language?: SDiffLanguage
67
+ }
68
+
69
+ export interface SDiffSummary {
70
+ readonly beforeLineCount: number
71
+ readonly afterLineCount: number
72
+ readonly unchangedLineCount: number
73
+ readonly removedLineCount: number
74
+ readonly addedLineCount: number
75
+ readonly changedLineCount: number
76
+ }
77
+
78
+ /** Serializable, presentation-neutral result shared by synchronous and worker execution paths. */
79
+ export interface SDiffResult {
80
+ readonly before: SDiffSourceSnapshot
81
+ readonly after: SDiffSourceSnapshot
82
+ readonly lines: readonly SDiffLine[]
83
+ readonly hunks: readonly SDiffHunk[]
84
+ readonly gaps: readonly SDiffGap[]
85
+ readonly summary: SDiffSummary
86
+ }
87
+
88
+ export const S_DIFF_DEFAULT_CONTEXT_LINES = 3
89
+ export const S_DIFF_MAX_CONTEXT_LINES = 100
90
+
91
+ const SOURCE_KEYS = new Set(['text', 'label', 'revision', 'language'])
92
+ const OPTIONS_KEYS = new Set(['contextLines'])
93
+ const RESULT_KEYS = new Set(['before', 'after', 'lines', 'hunks', 'gaps', 'summary'])
94
+ const SNAPSHOT_KEYS = new Set(['lineCount', 'label', 'revision', 'language'])
95
+ const LINE_KEYS = new Set(['id', 'kind', 'before', 'after'])
96
+ const LINE_SIDE_KEYS = new Set(['lineNumber', 'text', 'lineBreak'])
97
+ const HUNK_KEYS = new Set([
98
+ 'id',
99
+ 'lineStart',
100
+ 'lineEnd',
101
+ 'beforeStart',
102
+ 'beforeLineCount',
103
+ 'afterStart',
104
+ 'afterLineCount',
105
+ ])
106
+ const GAP_KEYS = new Set(['id', 'lineStart', 'lineEnd', 'beforeStart', 'afterStart', 'lineCount'])
107
+ const SUMMARY_KEYS = new Set([
108
+ 'beforeLineCount',
109
+ 'afterLineCount',
110
+ 'unchangedLineCount',
111
+ 'removedLineCount',
112
+ 'addedLineCount',
113
+ 'changedLineCount',
114
+ ])
115
+ const LINE_KINDS = ['unchanged', 'removed', 'added'] as const
116
+ const LINE_BREAKS = ['lf', 'crlf', 'none'] as const
117
+
118
+ function assertPlainExactRecord(
119
+ owner: string,
120
+ coordinate: string,
121
+ value: unknown,
122
+ allowed: ReadonlySet<string>,
123
+ ): Record<string, unknown> {
124
+ if (
125
+ typeof value !== 'object'
126
+ || value === null
127
+ || Array.isArray(value)
128
+ || Object.getPrototypeOf(value) !== Object.prototype
129
+ ) {
130
+ throw new TypeError(
131
+ `${owner}: ${coordinate} должен быть plain object. / ${owner}: ${coordinate} must be a plain object.`,
132
+ )
133
+ }
134
+ const record = value as Record<string, unknown>
135
+ const unexpected = Object.keys(record).filter((key) => !allowed.has(key))
136
+ if (unexpected.length > 0) {
137
+ throw new TypeError(
138
+ `${owner}.${coordinate}: неизвестные поля ${unexpected.map((key) => JSON.stringify(key)).join(', ')}. `
139
+ + `/ ${owner}.${coordinate}: unknown fields ${unexpected.map((key) => JSON.stringify(key)).join(', ')}.`,
140
+ )
141
+ }
142
+ return record
143
+ }
144
+
145
+ function validateStableString(owner: string, coordinate: string, value: unknown): string {
146
+ const validated = validateNonEmptyString(owner, coordinate, value)
147
+ if (validated !== validated.trim()) {
148
+ throw new TypeError(
149
+ `${owner}: ${coordinate} не должен содержать окружающие пробелы. `
150
+ + `/ ${owner}: ${coordinate} must not contain surrounding whitespace.`,
151
+ )
152
+ }
153
+ return validated
154
+ }
155
+
156
+ function validateRevision(owner: string, coordinate: string, value: unknown): SDiffSourceRevision {
157
+ if (typeof value === 'string') return validateStableString(owner, coordinate, value)
158
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) return value
159
+ throw new TypeError(
160
+ `${owner}: ${coordinate} должен быть непустой строкой или неотрицательным safe integer. `
161
+ + `/ ${owner}: ${coordinate} must be a non-empty string or non-negative safe integer.`,
162
+ )
163
+ }
164
+
165
+ function validateLanguage(
166
+ owner: string,
167
+ coordinate: string,
168
+ value: unknown,
169
+ ): SDiffLanguage {
170
+ const language = validateCodeEditorLanguage(`${owner}.${coordinate}`, value)
171
+ return Object.freeze(language)
172
+ }
173
+
174
+ /** Validates an exact source record without coercion or newline normalization. */
175
+ export function validateDiffSource(owner: string, coordinate: string, value: unknown): SDiffSource {
176
+ const record = assertPlainExactRecord(owner, coordinate, value, SOURCE_KEYS)
177
+ if (typeof record.text !== 'string') {
178
+ throw new TypeError(
179
+ `${owner}: ${coordinate}.text должен быть string. / ${owner}: ${coordinate}.text must be a string.`,
180
+ )
181
+ }
182
+ return Object.freeze({
183
+ text: record.text,
184
+ ...(record.label === undefined
185
+ ? {}
186
+ : { label: validateNonEmptyString(owner, `${coordinate}.label`, record.label) }),
187
+ ...(record.revision === undefined
188
+ ? {}
189
+ : { revision: validateRevision(owner, `${coordinate}.revision`, record.revision) }),
190
+ ...(record.language === undefined
191
+ ? {}
192
+ : { language: validateLanguage(owner, `${coordinate}.language`, record.language) }),
193
+ })
194
+ }
195
+
196
+ /** Validates computation options and supplies the documented context default. */
197
+ export function validateDiffComputationOptions(
198
+ owner: string,
199
+ value: unknown,
200
+ ): Required<SDiffComputationOptions> {
201
+ const record = value === undefined
202
+ ? {}
203
+ : assertPlainExactRecord(owner, 'options', value, OPTIONS_KEYS)
204
+ return Object.freeze({
205
+ contextLines: validateBoundedInteger(
206
+ owner,
207
+ 'options.contextLines',
208
+ record.contextLines ?? S_DIFF_DEFAULT_CONTEXT_LINES,
209
+ { minimum: 0, maximum: S_DIFF_MAX_CONTEXT_LINES },
210
+ ),
211
+ })
212
+ }
213
+
214
+ function validateSnapshot(
215
+ owner: string,
216
+ coordinate: string,
217
+ value: unknown,
218
+ ): SDiffSourceSnapshot {
219
+ const record = assertPlainExactRecord(owner, coordinate, value, SNAPSHOT_KEYS)
220
+ return Object.freeze({
221
+ lineCount: validateBoundedInteger(owner, `${coordinate}.lineCount`, record.lineCount, {
222
+ minimum: 0,
223
+ maximum: Number.MAX_SAFE_INTEGER,
224
+ }),
225
+ ...(record.label === undefined
226
+ ? {}
227
+ : { label: validateNonEmptyString(owner, `${coordinate}.label`, record.label) }),
228
+ ...(record.revision === undefined
229
+ ? {}
230
+ : { revision: validateRevision(owner, `${coordinate}.revision`, record.revision) }),
231
+ ...(record.language === undefined
232
+ ? {}
233
+ : { language: validateLanguage(owner, `${coordinate}.language`, record.language) }),
234
+ })
235
+ }
236
+
237
+ function validateLineSide(
238
+ owner: string,
239
+ coordinate: string,
240
+ value: unknown,
241
+ maximumLineNumber: number,
242
+ ): SDiffLineSide {
243
+ const record = assertPlainExactRecord(owner, coordinate, value, LINE_SIDE_KEYS)
244
+ if (typeof record.text !== 'string') {
245
+ throw new TypeError(`${owner}: ${coordinate}.text должен быть string. / ${owner}: ${coordinate}.text must be a string.`)
246
+ }
247
+ return Object.freeze({
248
+ lineNumber: validateBoundedInteger(owner, `${coordinate}.lineNumber`, record.lineNumber, {
249
+ minimum: 1,
250
+ maximum: maximumLineNumber,
251
+ }),
252
+ text: record.text,
253
+ lineBreak: validateExactString(owner, `${coordinate}.lineBreak`, record.lineBreak, LINE_BREAKS),
254
+ })
255
+ }
256
+
257
+ function validateLine(
258
+ owner: string,
259
+ coordinate: string,
260
+ value: unknown,
261
+ beforeLineCount: number,
262
+ afterLineCount: number,
263
+ ): SDiffLine {
264
+ const record = assertPlainExactRecord(owner, coordinate, value, LINE_KEYS)
265
+ const kind = validateExactString(owner, `${coordinate}.kind`, record.kind, LINE_KINDS)
266
+ const before = record.before === undefined
267
+ ? undefined
268
+ : validateLineSide(owner, `${coordinate}.before`, record.before, beforeLineCount)
269
+ const after = record.after === undefined
270
+ ? undefined
271
+ : validateLineSide(owner, `${coordinate}.after`, record.after, afterLineCount)
272
+ if (kind === 'unchanged' && (before === undefined || after === undefined)) {
273
+ throw new TypeError(`${owner}: ${coordinate} unchanged line требует обе стороны. / unchanged line requires both sides.`)
274
+ }
275
+ if (kind === 'removed' && (before === undefined || after !== undefined)) {
276
+ throw new TypeError(`${owner}: ${coordinate} removed line требует только before. / removed line requires before only.`)
277
+ }
278
+ if (kind === 'added' && (before !== undefined || after === undefined)) {
279
+ throw new TypeError(`${owner}: ${coordinate} added line требует только after. / added line requires after only.`)
280
+ }
281
+ if (
282
+ kind === 'unchanged'
283
+ && before !== undefined
284
+ && after !== undefined
285
+ && (before.text !== after.text || before.lineBreak !== after.lineBreak)
286
+ ) {
287
+ throw new TypeError(`${owner}: ${coordinate} unchanged line содержит разные стороны. / unchanged line contains different sides.`)
288
+ }
289
+ return Object.freeze({
290
+ id: validateStableString(owner, `${coordinate}.id`, record.id),
291
+ kind,
292
+ ...(before === undefined ? {} : { before }),
293
+ ...(after === undefined ? {} : { after }),
294
+ })
295
+ }
296
+
297
+ function validateHunk(
298
+ owner: string,
299
+ coordinate: string,
300
+ value: unknown,
301
+ lineCount: number,
302
+ beforeLineCount: number,
303
+ afterLineCount: number,
304
+ ): SDiffHunk {
305
+ const record = assertPlainExactRecord(owner, coordinate, value, HUNK_KEYS)
306
+ const lineStart = validateBoundedInteger(owner, `${coordinate}.lineStart`, record.lineStart, {
307
+ minimum: 0,
308
+ maximum: lineCount,
309
+ })
310
+ const lineEnd = validateBoundedInteger(owner, `${coordinate}.lineEnd`, record.lineEnd, {
311
+ minimum: lineStart + 1,
312
+ maximum: lineCount,
313
+ })
314
+ return Object.freeze({
315
+ id: validateStableString(owner, `${coordinate}.id`, record.id),
316
+ lineStart,
317
+ lineEnd,
318
+ beforeStart: validateBoundedInteger(owner, `${coordinate}.beforeStart`, record.beforeStart, {
319
+ minimum: 1,
320
+ maximum: beforeLineCount + 1,
321
+ }),
322
+ beforeLineCount: validateBoundedInteger(owner, `${coordinate}.beforeLineCount`, record.beforeLineCount, {
323
+ minimum: 0,
324
+ maximum: beforeLineCount,
325
+ }),
326
+ afterStart: validateBoundedInteger(owner, `${coordinate}.afterStart`, record.afterStart, {
327
+ minimum: 1,
328
+ maximum: afterLineCount + 1,
329
+ }),
330
+ afterLineCount: validateBoundedInteger(owner, `${coordinate}.afterLineCount`, record.afterLineCount, {
331
+ minimum: 0,
332
+ maximum: afterLineCount,
333
+ }),
334
+ })
335
+ }
336
+
337
+ function validateGap(
338
+ owner: string,
339
+ coordinate: string,
340
+ value: unknown,
341
+ lineCount: number,
342
+ beforeLineCount: number,
343
+ afterLineCount: number,
344
+ ): SDiffGap {
345
+ const record = assertPlainExactRecord(owner, coordinate, value, GAP_KEYS)
346
+ const lineStart = validateBoundedInteger(owner, `${coordinate}.lineStart`, record.lineStart, {
347
+ minimum: 0,
348
+ maximum: lineCount,
349
+ })
350
+ const lineEnd = validateBoundedInteger(owner, `${coordinate}.lineEnd`, record.lineEnd, {
351
+ minimum: lineStart + 1,
352
+ maximum: lineCount,
353
+ })
354
+ const intervalLength = lineEnd - lineStart
355
+ return Object.freeze({
356
+ id: validateStableString(owner, `${coordinate}.id`, record.id),
357
+ lineStart,
358
+ lineEnd,
359
+ beforeStart: validateBoundedInteger(owner, `${coordinate}.beforeStart`, record.beforeStart, {
360
+ minimum: 1,
361
+ maximum: beforeLineCount,
362
+ }),
363
+ afterStart: validateBoundedInteger(owner, `${coordinate}.afterStart`, record.afterStart, {
364
+ minimum: 1,
365
+ maximum: afterLineCount,
366
+ }),
367
+ lineCount: validateBoundedInteger(owner, `${coordinate}.lineCount`, record.lineCount, {
368
+ minimum: intervalLength,
369
+ maximum: intervalLength,
370
+ }),
371
+ })
372
+ }
373
+
374
+ function validateSummary(owner: string, value: unknown): SDiffSummary {
375
+ const record = assertPlainExactRecord(owner, 'summary', value, SUMMARY_KEYS)
376
+ const readCount = (coordinate: string): number => validateBoundedInteger(
377
+ owner,
378
+ `summary.${coordinate}`,
379
+ record[coordinate],
380
+ { minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
381
+ )
382
+ return Object.freeze({
383
+ beforeLineCount: readCount('beforeLineCount'),
384
+ afterLineCount: readCount('afterLineCount'),
385
+ unchangedLineCount: readCount('unchangedLineCount'),
386
+ removedLineCount: readCount('removedLineCount'),
387
+ addedLineCount: readCount('addedLineCount'),
388
+ changedLineCount: readCount('changedLineCount'),
389
+ })
390
+ }
391
+
392
+ /** Validates a worker or persisted result and all cross-record line/count invariants. */
393
+ export function validateDiffResult(owner: string, value: unknown): SDiffResult {
394
+ const record = assertPlainExactRecord(owner, 'result', value, RESULT_KEYS)
395
+ const before = validateSnapshot(owner, 'result.before', record.before)
396
+ const after = validateSnapshot(owner, 'result.after', record.after)
397
+ if (!Array.isArray(record.lines) || !Array.isArray(record.hunks) || !Array.isArray(record.gaps)) {
398
+ throw new TypeError(`${owner}: result lines/hunks/gaps должны быть arrays. / result lines/hunks/gaps must be arrays.`)
399
+ }
400
+ const lines = record.lines.map((line, index) => validateLine(
401
+ owner,
402
+ `result.lines[${index}]`,
403
+ line,
404
+ before.lineCount,
405
+ after.lineCount,
406
+ ))
407
+ let nextBeforeLine = 1
408
+ let nextAfterLine = 1
409
+ const beforeSides: SDiffLineSide[] = []
410
+ const afterSides: SDiffLineSide[] = []
411
+ const counts = { unchanged: 0, removed: 0, added: 0 }
412
+ for (const [index, line] of lines.entries()) {
413
+ if (line.before !== undefined && line.before.lineNumber !== nextBeforeLine) {
414
+ throw new RangeError(`${owner}: result.lines[${index}].before.lineNumber нарушает последовательность.`)
415
+ }
416
+ if (line.after !== undefined && line.after.lineNumber !== nextAfterLine) {
417
+ throw new RangeError(`${owner}: result.lines[${index}].after.lineNumber нарушает последовательность.`)
418
+ }
419
+ if (line.before !== undefined) {
420
+ beforeSides.push(line.before)
421
+ nextBeforeLine += 1
422
+ }
423
+ if (line.after !== undefined) {
424
+ afterSides.push(line.after)
425
+ nextAfterLine += 1
426
+ }
427
+ counts[line.kind] += 1
428
+ }
429
+ if (nextBeforeLine - 1 !== before.lineCount || nextAfterLine - 1 !== after.lineCount) {
430
+ throw new RangeError(`${owner}: result line inventory не соответствует source snapshots.`)
431
+ }
432
+ for (const [sideName, sides] of [['before', beforeSides], ['after', afterSides]] as const) {
433
+ const invalidTerminatorIndex = sides.findIndex(
434
+ (side, index) => index < sides.length - 1 && side.lineBreak === 'none',
435
+ )
436
+ if (invalidTerminatorIndex !== -1) {
437
+ throw new RangeError(
438
+ `${owner}: result ${sideName} line ${invalidTerminatorIndex + 1} теряет межстрочный terminator.`,
439
+ )
440
+ }
441
+ }
442
+ const hunks = record.hunks.map((hunk, index) => validateHunk(
443
+ owner,
444
+ `result.hunks[${index}]`,
445
+ hunk,
446
+ lines.length,
447
+ before.lineCount,
448
+ after.lineCount,
449
+ ))
450
+ const gaps = record.gaps.map((gap, index) => validateGap(
451
+ owner,
452
+ `result.gaps[${index}]`,
453
+ gap,
454
+ lines.length,
455
+ before.lineCount,
456
+ after.lineCount,
457
+ ))
458
+ for (const [index, hunk] of hunks.entries()) {
459
+ if (!lines.slice(hunk.lineStart, hunk.lineEnd).some((line) => line.kind !== 'unchanged')) {
460
+ throw new RangeError(`${owner}: result.hunks[${index}] не содержит changed line.`)
461
+ }
462
+ }
463
+ for (const [index, gap] of gaps.entries()) {
464
+ if (lines.slice(gap.lineStart, gap.lineEnd).some((line) => line.kind !== 'unchanged')) {
465
+ throw new RangeError(`${owner}: result.gaps[${index}] содержит changed line.`)
466
+ }
467
+ }
468
+ const ids = new Set<string>()
469
+ for (const segment of [...hunks, ...gaps]) {
470
+ if (ids.has(segment.id)) throw new RangeError(`${owner}: duplicate result segment id ${JSON.stringify(segment.id)}.`)
471
+ ids.add(segment.id)
472
+ }
473
+ if (counts.removed + counts.added === 0) {
474
+ if (hunks.length !== 0 || gaps.length !== 0) {
475
+ throw new RangeError(`${owner}: unchanged result не должен содержать hunks/gaps.`)
476
+ }
477
+ } else {
478
+ const segments = [
479
+ ...hunks.map((hunk) => ({ kind: 'hunk' as const, value: hunk })),
480
+ ...gaps.map((gap) => ({ kind: 'gap' as const, value: gap })),
481
+ ].sort((left, right) => left.value.lineStart - right.value.lineStart)
482
+ let expectedStart = 0
483
+ for (const segment of segments) {
484
+ if (segment.value.lineStart !== expectedStart) {
485
+ throw new RangeError(`${owner}: result hunks/gaps не образуют exact partition lines.`)
486
+ }
487
+ expectedStart = segment.value.lineEnd
488
+ const slice = lines.slice(segment.value.lineStart, segment.value.lineEnd)
489
+ const beforeCount = slice.filter((line) => line.before !== undefined).length
490
+ const afterCount = slice.filter((line) => line.after !== undefined).length
491
+ const expectedBeforeStart = lines
492
+ .slice(0, segment.value.lineStart)
493
+ .filter((line) => line.before !== undefined).length + 1
494
+ const expectedAfterStart = lines
495
+ .slice(0, segment.value.lineStart)
496
+ .filter((line) => line.after !== undefined).length + 1
497
+ if (
498
+ segment.value.beforeStart !== expectedBeforeStart
499
+ || segment.value.afterStart !== expectedAfterStart
500
+ || (segment.kind === 'hunk'
501
+ && (segment.value.beforeLineCount !== beforeCount || segment.value.afterLineCount !== afterCount))
502
+ ) {
503
+ throw new RangeError(`${owner}: result segment coordinates не соответствуют lines.`)
504
+ }
505
+ }
506
+ if (expectedStart !== lines.length) {
507
+ throw new RangeError(`${owner}: result hunks/gaps не покрывают lines полностью.`)
508
+ }
509
+ }
510
+ const summary = validateSummary(owner, record.summary)
511
+ if (
512
+ summary.beforeLineCount !== before.lineCount
513
+ || summary.afterLineCount !== after.lineCount
514
+ || summary.unchangedLineCount !== counts.unchanged
515
+ || summary.removedLineCount !== counts.removed
516
+ || summary.addedLineCount !== counts.added
517
+ || summary.changedLineCount !== counts.removed + counts.added
518
+ ) {
519
+ throw new RangeError(`${owner}: result.summary не соответствует lines/source snapshots.`)
520
+ }
521
+ return Object.freeze({
522
+ before,
523
+ after,
524
+ lines: Object.freeze(lines),
525
+ hunks: Object.freeze(hunks),
526
+ gaps: Object.freeze(gaps),
527
+ summary,
528
+ })
529
+ }
530
+
531
+ /** Computes the canonical deterministic result on the current thread. Large UI inputs use the worker runtime. */
532
+ export function computeDiff(
533
+ beforeValue: unknown,
534
+ afterValue: unknown,
535
+ optionsValue?: unknown,
536
+ ): SDiffResult {
537
+ const before = validateDiffSource('computeDiff', 'before', beforeValue)
538
+ const after = validateDiffSource('computeDiff', 'after', afterValue)
539
+ const options = validateDiffComputationOptions('computeDiff', optionsValue)
540
+ return computeDiffModel(before, after, options)
541
+ }