@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,102 @@
1
+ <template>
2
+ <component
3
+ :is="validatedDisplay === 'block' ? 'div' : 'span'"
4
+ class="s-rich-math"
5
+ :data-display="validatedDisplay"
6
+ :data-state="state"
7
+ >
8
+ <span ref="surface" class="s-rich-math__surface" />
9
+ <code v-if="state === 'loading'" class="s-rich-math__fallback">{{ validatedSource }}</code>
10
+ <span v-if="state === 'loading'" class="s-rich-math__assistive" role="status">{{ t('editor.mathLoading') }}</span>
11
+ <span v-if="state === 'error'" class="s-rich-math__error" role="alert">
12
+ <span>{{ t('editor.mathError') }}</span>
13
+ <code>{{ validatedSource }}</code>
14
+ </span>
15
+ </component>
16
+ </template>
17
+
18
+ <script setup lang="ts">
19
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
20
+ import 'katex/dist/katex.min.css'
21
+
22
+ import { useUiKitLocale } from '../../../../composables/useUiKitLocale'
23
+ import { validateExactString, validateNonEmptyString } from '../../../../internal/runtimeContract'
24
+
25
+ defineOptions({ inheritAttrs: false })
26
+
27
+ const props = withDefaults(defineProps<{
28
+ source: string
29
+ display?: 'inline' | 'block'
30
+ label?: string
31
+ }>(), {
32
+ display: 'inline',
33
+ label: undefined,
34
+ })
35
+
36
+ const { t } = useUiKitLocale()
37
+ const surface = ref<HTMLElement | null>(null)
38
+ const state = ref<'loading' | 'ready' | 'error'>('loading')
39
+ const validatedSource = computed(() => validateNonEmptyString('SRichMath', 'source', props.source))
40
+ const validatedDisplay = computed(() => validateExactString(
41
+ 'SRichMath',
42
+ 'display',
43
+ props.display,
44
+ ['inline', 'block'] as const,
45
+ ))
46
+ const validatedLabel = computed(() => props.label === undefined
47
+ ? validatedSource.value
48
+ : validateNonEmptyString('SRichMath', 'label', props.label))
49
+ let mounted = false
50
+ let generation = 0
51
+
52
+ async function renderMath(): Promise<void> {
53
+ if (!mounted) return
54
+ const target = surface.value
55
+ if (!target) throw new Error('SRichMath: render surface is unavailable.')
56
+ const currentGeneration = generation += 1
57
+ state.value = 'loading'
58
+ target.replaceChildren()
59
+ try {
60
+ const katex = await import('katex')
61
+ if (currentGeneration !== generation || !mounted) return
62
+ katex.default.render(validatedSource.value, target, {
63
+ displayMode: validatedDisplay.value === 'block',
64
+ output: 'htmlAndMathml',
65
+ strict: 'error',
66
+ throwOnError: true,
67
+ trust: false,
68
+ maxExpand: 1_000,
69
+ maxSize: 20,
70
+ })
71
+ target.setAttribute('role', 'math')
72
+ target.setAttribute('aria-label', validatedLabel.value)
73
+ state.value = 'ready'
74
+ } catch {
75
+ if (currentGeneration !== generation || !mounted) return
76
+ target.replaceChildren()
77
+ target.removeAttribute('role')
78
+ target.removeAttribute('aria-label')
79
+ state.value = 'error'
80
+ }
81
+ }
82
+
83
+ onMounted(() => {
84
+ mounted = true
85
+ void renderMath()
86
+ })
87
+
88
+ watch(
89
+ () => [props.source, props.display, props.label] as const,
90
+ () => { void renderMath() },
91
+ )
92
+
93
+ onBeforeUnmount(() => {
94
+ mounted = false
95
+ generation += 1
96
+ surface.value?.replaceChildren()
97
+ surface.value?.removeAttribute('role')
98
+ surface.value?.removeAttribute('aria-label')
99
+ })
100
+ </script>
101
+
102
+ <style lang="postcss" src="./SRichMath.css"></style>
@@ -0,0 +1,200 @@
1
+ import {
2
+ validateBoolean,
3
+ validateBoundedInteger,
4
+ validateExactString,
5
+ validateNonEmptyString,
6
+ } from '../../../internal/runtimeContract'
7
+
8
+ /** Serializable half-open document range. */
9
+ export interface SCodeEditorRange {
10
+ readonly from: number
11
+ readonly to: number
12
+ }
13
+
14
+ /** Serializable primary selection. Direction is preserved through anchor/head. */
15
+ export interface SCodeEditorSelectionRange extends SCodeEditorRange {
16
+ readonly anchor: number
17
+ readonly head: number
18
+ readonly empty: boolean
19
+ }
20
+
21
+ /** Selection snapshot returned only through an explicit consumer action. */
22
+ export interface SCodeEditorSelectionSnapshot extends SCodeEditorSelectionRange {
23
+ readonly text: string
24
+ }
25
+
26
+ /** Serializable editor view state without a CodeMirror object or DOM reference. */
27
+ export interface SCodeEditorViewState {
28
+ readonly selection: SCodeEditorSelectionRange
29
+ readonly scrollTop: number
30
+ readonly scrollLeft: number
31
+ }
32
+
33
+ /** Serializable imperative surface; CodeMirror objects and DOM nodes never cross the public boundary. */
34
+ export interface SCodeEditorApi {
35
+ applySelection(position: number): void
36
+ revealRange(range: SCodeEditorRange): void
37
+ getSelection(): SCodeEditorSelectionSnapshot
38
+ captureViewState(): SCodeEditorViewState
39
+ applyViewState(viewState: SCodeEditorViewState): void
40
+ focus(): void
41
+ }
42
+
43
+ export const S_CODE_EDITOR_DIAGNOSTIC_SEVERITIES = ['error', 'warning', 'info', 'hint'] as const
44
+ export type SCodeEditorDiagnosticSeverity = (typeof S_CODE_EDITOR_DIAGNOSTIC_SEVERITIES)[number]
45
+ export type SCodeEditorDiagnosticId = string | number
46
+
47
+ /** Controlled diagnostic record rendered by SCodeEditor. */
48
+ export interface SCodeEditorDiagnostic<Id extends SCodeEditorDiagnosticId = string> extends SCodeEditorRange {
49
+ readonly id: Id
50
+ readonly severity: SCodeEditorDiagnosticSeverity
51
+ readonly message: string
52
+ readonly source?: string
53
+ readonly code?: string | number
54
+ }
55
+
56
+ const RANGE_KEYS = new Set(['from', 'to'])
57
+ const SELECTION_KEYS = new Set(['anchor', 'head', 'from', 'to', 'empty'])
58
+ const VIEW_STATE_KEYS = new Set(['selection', 'scrollTop', 'scrollLeft'])
59
+ const DIAGNOSTIC_KEYS = new Set(['id', 'severity', 'message', 'from', 'to', 'source', 'code'])
60
+
61
+ function assertPlainExactRecord(
62
+ owner: string,
63
+ coordinate: string,
64
+ value: unknown,
65
+ allowed: ReadonlySet<string>,
66
+ ): Record<string, unknown> {
67
+ if (
68
+ typeof value !== 'object'
69
+ || value === null
70
+ || Array.isArray(value)
71
+ || Object.getPrototypeOf(value) !== Object.prototype
72
+ ) {
73
+ throw new TypeError(
74
+ `${owner}: ${coordinate} должен быть plain object. / ${owner}: ${coordinate} must be a plain object.`,
75
+ )
76
+ }
77
+ const record = value as Record<string, unknown>
78
+ const unknownFields = Object.keys(record).filter((key) => !allowed.has(key))
79
+ if (unknownFields.length > 0) {
80
+ throw new TypeError(
81
+ `${owner}.${coordinate}: неизвестные поля ${unknownFields.map((key) => JSON.stringify(key)).join(', ')}. `
82
+ + `/ ${owner}.${coordinate}: unknown fields ${unknownFields.map((key) => JSON.stringify(key)).join(', ')}.`,
83
+ )
84
+ }
85
+ return record
86
+ }
87
+
88
+ function validateNonNegativeFiniteNumber(owner: string, coordinate: string, value: unknown): number {
89
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value
90
+ throw new TypeError(
91
+ `${owner}: ${coordinate} должен быть конечным неотрицательным числом. `
92
+ + `/ ${owner}: ${coordinate} must be a finite non-negative number.`,
93
+ )
94
+ }
95
+
96
+ function validateDiagnosticId(owner: string, coordinate: string, value: unknown): SCodeEditorDiagnosticId {
97
+ if (typeof value === 'string') return validateNonEmptyString(owner, coordinate, value)
98
+ if (typeof value === 'number' && Number.isFinite(value)) return value
99
+ throw new TypeError(
100
+ `${owner}: ${coordinate} должен быть непустой строкой или конечным числом. `
101
+ + `/ ${owner}: ${coordinate} must be a non-empty string or finite number.`,
102
+ )
103
+ }
104
+
105
+ /** Validates a half-open range against the current document without clamping. */
106
+ export function validateCodeEditorRange(
107
+ owner: string,
108
+ coordinate: string,
109
+ value: unknown,
110
+ documentLength: number,
111
+ ): SCodeEditorRange {
112
+ const length = validateBoundedInteger(owner, 'documentLength', documentLength, {
113
+ minimum: 0,
114
+ maximum: Number.MAX_SAFE_INTEGER,
115
+ })
116
+ const record = assertPlainExactRecord(owner, coordinate, value, RANGE_KEYS)
117
+ const from = validateBoundedInteger(owner, `${coordinate}.from`, record.from, { minimum: 0, maximum: length })
118
+ const to = validateBoundedInteger(owner, `${coordinate}.to`, record.to, { minimum: from, maximum: length })
119
+ return Object.freeze({ from, to })
120
+ }
121
+
122
+ /** Validates the primary selection and its derived invariants. */
123
+ export function validateCodeEditorSelectionRange(
124
+ owner: string,
125
+ coordinate: string,
126
+ value: unknown,
127
+ documentLength: number,
128
+ ): SCodeEditorSelectionRange {
129
+ const record = assertPlainExactRecord(owner, coordinate, value, SELECTION_KEYS)
130
+ const anchor = validateBoundedInteger(owner, `${coordinate}.anchor`, record.anchor, {
131
+ minimum: 0,
132
+ maximum: documentLength,
133
+ })
134
+ const head = validateBoundedInteger(owner, `${coordinate}.head`, record.head, {
135
+ minimum: 0,
136
+ maximum: documentLength,
137
+ })
138
+ const expectedFrom = Math.min(anchor, head)
139
+ const expectedTo = Math.max(anchor, head)
140
+ const from = validateBoundedInteger(owner, `${coordinate}.from`, record.from, {
141
+ minimum: expectedFrom,
142
+ maximum: expectedFrom,
143
+ })
144
+ const to = validateBoundedInteger(owner, `${coordinate}.to`, record.to, {
145
+ minimum: expectedTo,
146
+ maximum: expectedTo,
147
+ })
148
+ const empty = validateBoolean(owner, `${coordinate}.empty`, record.empty)
149
+ if (empty !== (anchor === head)) {
150
+ throw new TypeError(
151
+ `${owner}: ${coordinate}.empty не соответствует anchor/head. `
152
+ + `/ ${owner}: ${coordinate}.empty does not match anchor/head.`,
153
+ )
154
+ }
155
+ return Object.freeze({ anchor, head, from, to, empty })
156
+ }
157
+
158
+ /** Validates a serializable view-state snapshot against the current document. */
159
+ export function validateCodeEditorViewState(
160
+ owner: string,
161
+ value: unknown,
162
+ documentLength: number,
163
+ ): SCodeEditorViewState {
164
+ const record = assertPlainExactRecord(owner, 'viewState', value, VIEW_STATE_KEYS)
165
+ return Object.freeze({
166
+ selection: validateCodeEditorSelectionRange(owner, 'viewState.selection', record.selection, documentLength),
167
+ scrollTop: validateNonNegativeFiniteNumber(owner, 'viewState.scrollTop', record.scrollTop),
168
+ scrollLeft: validateNonNegativeFiniteNumber(owner, 'viewState.scrollLeft', record.scrollLeft),
169
+ })
170
+ }
171
+
172
+ /** Runtime-validates controlled diagnostics and preserves their consumer-owned IDs. */
173
+ export function validateCodeEditorDiagnostics(
174
+ owner: string,
175
+ value: unknown,
176
+ documentLength: number,
177
+ ): readonly SCodeEditorDiagnostic<SCodeEditorDiagnosticId>[] {
178
+ if (!Array.isArray(value)) {
179
+ throw new TypeError(`${owner}: diagnostics должен быть массивом. / ${owner}: diagnostics must be an array.`)
180
+ }
181
+ const ids = new Set<SCodeEditorDiagnosticId>()
182
+ value.forEach((candidate: unknown, index: number) => {
183
+ const coordinate = `diagnostics[${index}]`
184
+ const record = assertPlainExactRecord(owner, coordinate, candidate, DIAGNOSTIC_KEYS)
185
+ const id = validateDiagnosticId(owner, `${coordinate}.id`, record.id)
186
+ if (ids.has(id)) {
187
+ throw new TypeError(
188
+ `${owner}: ${coordinate}.id дублирует ${JSON.stringify(id)}. `
189
+ + `/ ${owner}: ${coordinate}.id duplicates ${JSON.stringify(id)}.`,
190
+ )
191
+ }
192
+ ids.add(id)
193
+ validateExactString(owner, `${coordinate}.severity`, record.severity, S_CODE_EDITOR_DIAGNOSTIC_SEVERITIES)
194
+ validateNonEmptyString(owner, `${coordinate}.message`, record.message)
195
+ validateCodeEditorRange(owner, coordinate, { from: record.from, to: record.to }, documentLength)
196
+ if (record.source !== undefined) validateNonEmptyString(owner, `${coordinate}.source`, record.source)
197
+ if (record.code !== undefined) validateDiagnosticId(owner, `${coordinate}.code`, record.code)
198
+ })
199
+ return value as readonly SCodeEditorDiagnostic<SCodeEditorDiagnosticId>[]
200
+ }
@@ -0,0 +1,233 @@
1
+ import {
2
+ validateBoolean,
3
+ validateNonEmptyString,
4
+ } from '../../../internal/runtimeContract'
5
+ import {
6
+ validateCodeEditorCapabilities,
7
+ validateCodeEditorLanguage,
8
+ validateCodeEditorVariant,
9
+ type CodeEditorCapabilitiesContract,
10
+ type CodeEditorLanguageContract,
11
+ } from '../../../internal/codeEditorContract'
12
+ import {
13
+ validateCodeEditorDiagnostics,
14
+ type SCodeEditorDiagnostic,
15
+ type SCodeEditorDiagnosticId,
16
+ type SCodeEditorRange,
17
+ type SCodeEditorSelectionRange,
18
+ type SCodeEditorSelectionSnapshot,
19
+ type SCodeEditorViewState,
20
+ } from './codeEditor'
21
+
22
+ export type SCodeWorkspaceDocumentKey = string
23
+ export type SCodeWorkspaceSessionKey = string
24
+ export type SCodeWorkspaceRevision = string | number
25
+
26
+ /** Controlled document descriptor. Revision changes replace only this editor session and reset its local history. */
27
+ export interface SCodeWorkspaceDocument {
28
+ readonly key: SCodeWorkspaceDocumentKey
29
+ readonly sessionKey: SCodeWorkspaceSessionKey
30
+ readonly label: string
31
+ readonly value: string
32
+ readonly revision: SCodeWorkspaceRevision
33
+ readonly language: CodeEditorLanguageContract
34
+ readonly capabilities: CodeEditorCapabilitiesContract
35
+ readonly diagnostics?: readonly SCodeEditorDiagnostic<SCodeEditorDiagnosticId>[]
36
+ readonly readOnly?: boolean
37
+ readonly disabled?: boolean
38
+ readonly invalid?: boolean
39
+ readonly closable?: boolean
40
+ readonly placeholder?: string
41
+ readonly variant?: 'code' | 'sql'
42
+ }
43
+
44
+ /** Controlled text change. Revision stays unchanged until the consumer supplies an authoritative replacement. */
45
+ export interface SCodeWorkspaceDocumentChange {
46
+ readonly key: SCodeWorkspaceDocumentKey
47
+ readonly sessionKey: SCodeWorkspaceSessionKey
48
+ readonly revision: SCodeWorkspaceRevision
49
+ readonly value: string
50
+ }
51
+
52
+ export interface SCodeWorkspaceSelectionChange {
53
+ readonly key: SCodeWorkspaceDocumentKey
54
+ readonly sessionKey: SCodeWorkspaceSessionKey
55
+ readonly selection: SCodeEditorSelectionRange
56
+ }
57
+
58
+ export interface SCodeWorkspaceViewStateChange {
59
+ readonly key: SCodeWorkspaceDocumentKey
60
+ readonly sessionKey: SCodeWorkspaceSessionKey
61
+ readonly viewState: SCodeEditorViewState
62
+ }
63
+
64
+ export interface SCodeWorkspaceReleaseRequest {
65
+ readonly key: SCodeWorkspaceDocumentKey
66
+ readonly sessionKey: SCodeWorkspaceSessionKey
67
+ }
68
+
69
+ export interface SCodeWorkspaceDocumentState {
70
+ readonly key: SCodeWorkspaceDocumentKey
71
+ readonly sessionKey: SCodeWorkspaceSessionKey
72
+ readonly revision: SCodeWorkspaceRevision
73
+ readonly selection: SCodeEditorSelectionSnapshot
74
+ readonly viewState: SCodeEditorViewState
75
+ }
76
+
77
+ export interface SCodeWorkspaceEditorApi {
78
+ focusActive(): void
79
+ revealRange(key: SCodeWorkspaceDocumentKey, range: SCodeEditorRange): void
80
+ captureDocumentState(key: SCodeWorkspaceDocumentKey): SCodeWorkspaceDocumentState
81
+ releaseSession(sessionKey: SCodeWorkspaceSessionKey): void
82
+ }
83
+
84
+ const DOCUMENT_KEYS = new Set([
85
+ 'key',
86
+ 'sessionKey',
87
+ 'label',
88
+ 'value',
89
+ 'revision',
90
+ 'language',
91
+ 'capabilities',
92
+ 'diagnostics',
93
+ 'readOnly',
94
+ 'disabled',
95
+ 'invalid',
96
+ 'closable',
97
+ 'placeholder',
98
+ 'variant',
99
+ ])
100
+
101
+ function assertPlainExactRecord(
102
+ owner: string,
103
+ coordinate: string,
104
+ value: unknown,
105
+ allowed: ReadonlySet<string>,
106
+ ): Record<string, unknown> {
107
+ if (
108
+ typeof value !== 'object'
109
+ || value === null
110
+ || Array.isArray(value)
111
+ || Object.getPrototypeOf(value) !== Object.prototype
112
+ ) {
113
+ throw new TypeError(
114
+ `${owner}: ${coordinate} должен быть plain object. / ${owner}: ${coordinate} must be a plain object.`,
115
+ )
116
+ }
117
+ const record = value as Record<string, unknown>
118
+ const unexpected = Object.keys(record).filter((key) => !allowed.has(key))
119
+ if (unexpected.length > 0) {
120
+ throw new TypeError(
121
+ `${owner}.${coordinate}: неизвестные поля ${unexpected.map((key) => JSON.stringify(key)).join(', ')}. `
122
+ + `/ ${owner}.${coordinate}: unknown fields ${unexpected.map((key) => JSON.stringify(key)).join(', ')}.`,
123
+ )
124
+ }
125
+ return record
126
+ }
127
+
128
+ function validateStableKey(owner: string, coordinate: string, value: unknown): string {
129
+ const result = validateNonEmptyString(owner, coordinate, value)
130
+ if (result !== result.trim()) {
131
+ throw new TypeError(
132
+ `${owner}: ${coordinate} не должен содержать окружающие пробелы. `
133
+ + `/ ${owner}: ${coordinate} must not contain surrounding whitespace.`,
134
+ )
135
+ }
136
+ return result
137
+ }
138
+
139
+ function validateRevision(owner: string, coordinate: string, value: unknown): SCodeWorkspaceRevision {
140
+ if (typeof value === 'string') return validateStableKey(owner, coordinate, value)
141
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) return value
142
+ throw new TypeError(
143
+ `${owner}: ${coordinate} должен быть непустой строкой или неотрицательным safe integer. `
144
+ + `/ ${owner}: ${coordinate} must be a non-empty string or non-negative safe integer.`,
145
+ )
146
+ }
147
+
148
+ function validateOptionalBoolean(owner: string, coordinate: string, value: unknown, fallback: boolean): boolean {
149
+ return value === undefined ? fallback : validateBoolean(owner, coordinate, value)
150
+ }
151
+
152
+ function validateOptionalString(owner: string, coordinate: string, value: unknown): string | undefined {
153
+ return value === undefined ? undefined : validateNonEmptyString(owner, coordinate, value)
154
+ }
155
+
156
+ /** Validates and freezes the exact controlled document contract without normalizing invalid input. */
157
+ export function validateCodeWorkspaceDocument(
158
+ owner: string,
159
+ coordinate: string,
160
+ value: unknown,
161
+ ): SCodeWorkspaceDocument {
162
+ const record = assertPlainExactRecord(owner, coordinate, value, DOCUMENT_KEYS)
163
+ if (typeof record.value !== 'string') {
164
+ throw new TypeError(`${owner}: ${coordinate}.value должен быть строкой. / ${owner}: ${coordinate}.value must be a string.`)
165
+ }
166
+ const diagnostics = validateCodeEditorDiagnostics(
167
+ owner,
168
+ record.diagnostics ?? [],
169
+ record.value.length,
170
+ )
171
+ const placeholder = validateOptionalString(owner, `${coordinate}.placeholder`, record.placeholder)
172
+ const variant = record.variant === undefined
173
+ ? 'code'
174
+ : validateCodeEditorVariant(owner, record.variant)
175
+ return Object.freeze({
176
+ key: validateStableKey(owner, `${coordinate}.key`, record.key),
177
+ sessionKey: validateStableKey(owner, `${coordinate}.sessionKey`, record.sessionKey),
178
+ label: validateNonEmptyString(owner, `${coordinate}.label`, record.label),
179
+ value: record.value,
180
+ revision: validateRevision(owner, `${coordinate}.revision`, record.revision),
181
+ language: validateCodeEditorLanguage(owner, record.language),
182
+ capabilities: validateCodeEditorCapabilities(owner, record.capabilities),
183
+ diagnostics,
184
+ readOnly: validateOptionalBoolean(owner, `${coordinate}.readOnly`, record.readOnly, false),
185
+ disabled: validateOptionalBoolean(owner, `${coordinate}.disabled`, record.disabled, false),
186
+ invalid: validateOptionalBoolean(owner, `${coordinate}.invalid`, record.invalid, false),
187
+ closable: validateOptionalBoolean(owner, `${coordinate}.closable`, record.closable, true),
188
+ ...(placeholder === undefined ? {} : { placeholder }),
189
+ variant,
190
+ })
191
+ }
192
+
193
+ /** Validates document/session uniqueness for one workspace inventory. */
194
+ export function validateCodeWorkspaceDocuments(
195
+ owner: string,
196
+ value: unknown,
197
+ ): readonly SCodeWorkspaceDocument[] {
198
+ if (!Array.isArray(value)) {
199
+ throw new TypeError(`${owner}: documents должен быть массивом. / ${owner}: documents must be an array.`)
200
+ }
201
+ const keys = new Set<string>()
202
+ const sessionKeys = new Set<string>()
203
+ const documents = value.map((document, index) => {
204
+ const validated = validateCodeWorkspaceDocument(owner, `documents[${index}]`, document)
205
+ if (keys.has(validated.key)) {
206
+ throw new RangeError(`${owner}: duplicate document key ${JSON.stringify(validated.key)}.`)
207
+ }
208
+ if (sessionKeys.has(validated.sessionKey)) {
209
+ throw new RangeError(`${owner}: duplicate sessionKey ${JSON.stringify(validated.sessionKey)}.`)
210
+ }
211
+ keys.add(validated.key)
212
+ sessionKeys.add(validated.sessionKey)
213
+ return validated
214
+ })
215
+ return Object.freeze(documents)
216
+ }
217
+
218
+ /** Validates controlled selection and rejects missing active documents. */
219
+ export function validateCodeWorkspaceActiveKey(
220
+ owner: string,
221
+ value: unknown,
222
+ documents: readonly SCodeWorkspaceDocument[],
223
+ ): SCodeWorkspaceDocumentKey | null {
224
+ if (documents.length === 0) {
225
+ if (value === null) return null
226
+ throw new RangeError(`${owner}: activeKey должен быть null для пустого workspace. / ${owner}: activeKey must be null for an empty workspace.`)
227
+ }
228
+ const key = validateStableKey(owner, 'activeKey', value)
229
+ if (!documents.some((document) => document.key === key)) {
230
+ throw new RangeError(`${owner}: activeKey ${JSON.stringify(key)} отсутствует в documents. / ${owner}: activeKey ${JSON.stringify(key)} is absent from documents.`)
231
+ }
232
+ return key
233
+ }