@hachej/boring-ui-kit 0.1.111 → 0.2.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.
- package/LICENSE +1 -1
- package/README.md +255 -239
- package/THIRD_PARTY_NOTICES.md +85 -0
- package/dist/anchor-conformance.d.ts +78 -0
- package/dist/anchor-conformance.js +288 -0
- package/dist/canvas-document.d.ts +25 -0
- package/dist/canvas-document.js +141 -0
- package/dist/canvas-editor-native.d.ts +22 -0
- package/dist/canvas-editor-native.js +112 -0
- package/dist/canvas-editor.d.ts +6 -0
- package/dist/canvas-editor.js +13 -0
- package/dist/canvas-mounted.d.ts +41 -0
- package/dist/canvas-mounted.js +180 -0
- package/dist/canvas-proposals.d.ts +11 -0
- package/dist/canvas-proposals.js +45 -0
- package/dist/canvas-records.d.ts +3 -0
- package/dist/canvas-records.js +39 -0
- package/dist/canvas.d.ts +92 -0
- package/dist/canvas.js +264 -0
- package/dist/contracts.d.ts +125 -0
- package/dist/contracts.js +1 -0
- package/dist/experience-catalog.d.ts +142 -0
- package/dist/experience-catalog.js +25 -0
- package/dist/experience-compose.d.ts +83 -0
- package/dist/experience-compose.js +218 -0
- package/dist/experience-composition-schema.d.ts +29 -0
- package/dist/experience-composition-schema.js +22 -0
- package/dist/experience-document-viewer.d.ts +13 -0
- package/dist/experience-document-viewer.js +54 -0
- package/dist/experience-document.d.ts +64 -0
- package/dist/experience-document.js +234 -0
- package/dist/experience-region-text.d.ts +3 -0
- package/dist/experience-region-text.js +84 -0
- package/dist/experience-region-tree.d.ts +22 -0
- package/dist/experience-region-tree.js +50 -0
- package/dist/experience-regions.d.ts +9 -0
- package/dist/experience-regions.js +122 -0
- package/dist/experience-renderer.d.ts +16 -0
- package/dist/experience-renderer.js +37 -0
- package/dist/experience.d.ts +12 -0
- package/dist/experience.js +23 -0
- package/dist/file-tree-shadcn.d.ts +8 -0
- package/dist/file-tree-shadcn.js +14 -0
- package/dist/file-tree-view.d.ts +64 -0
- package/dist/file-tree-view.js +256 -0
- package/dist/file-tree.d.ts +57 -0
- package/dist/file-tree.js +204 -0
- package/dist/html-preview.d.ts +2 -0
- package/dist/html-preview.js +47 -0
- package/dist/html-viewer.d.ts +13 -0
- package/dist/html-viewer.js +69 -0
- package/dist/html.d.ts +21 -0
- package/dist/html.js +21 -0
- package/dist/index.d.ts +2 -472
- package/dist/index.js +1 -2671
- package/dist/markdown-editor.d.ts +28 -0
- package/dist/markdown-editor.js +230 -0
- package/dist/markdown-extensions.d.ts +16 -0
- package/dist/markdown-extensions.js +96 -0
- package/dist/markdown-icons.d.ts +244 -0
- package/dist/markdown-icons.js +32 -0
- package/dist/markdown-mounted.d.ts +63 -0
- package/dist/markdown-mounted.js +263 -0
- package/dist/markdown-parser.d.ts +4 -0
- package/dist/markdown-parser.js +33 -0
- package/dist/markdown-proposal-diff.d.ts +5 -0
- package/dist/markdown-proposal-diff.js +122 -0
- package/dist/markdown-safety.d.ts +21 -0
- package/dist/markdown-safety.js +135 -0
- package/dist/markdown.d.ts +86 -0
- package/dist/markdown.js +121 -0
- package/dist/native-chat.d.ts +150 -0
- package/dist/native-chat.js +376 -0
- package/dist/pi.d.ts +12 -0
- package/dist/pi.js +1 -0
- package/dist/public-text-buffer.d.ts +5 -0
- package/dist/public-text-buffer.js +1 -0
- package/dist/remote-chat.d.ts +76 -0
- package/dist/remote-chat.js +324 -0
- package/dist/resources.d.ts +34 -0
- package/dist/resources.js +1 -0
- package/dist/text-buffer-state.d.ts +24 -0
- package/dist/text-buffer-state.js +1 -0
- package/dist/text-buffer.d.ts +52 -0
- package/dist/text-buffer.js +315 -0
- package/dist/text-draft-controls.d.ts +9 -0
- package/dist/text-draft-controls.js +33 -0
- package/dist/text-draft-types.d.ts +111 -0
- package/dist/text-draft-types.js +1 -0
- package/dist/text-drafts.d.ts +29 -0
- package/dist/text-drafts.js +378 -0
- package/package.json +226 -58
- package/styles/file-tree.css +46 -0
- package/dist/styles.css +0 -2308
- package/dist/tokens.css +0 -155
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { experimental_composeSpec } from '@json-render/core';
|
|
3
|
+
import { compositionDefinition, compositionCandidate, compositionLimits, compositionId as newCompositionId } from './experience-composition-schema.js';
|
|
4
|
+
import { experienceCatalog, layoutProps, experienceIdentifier } from './experience-catalog.js';
|
|
5
|
+
const id = experienceIdentifier;
|
|
6
|
+
const element = z.strictObject({ type: z.enum(['boring/stack', 'boring/row', 'boring/grid', 'boring/cell', 'boring/generated']), props: z.record(z.string(), z.unknown()), children: z.array(id).max(200).default([]) });
|
|
7
|
+
const descriptor = z.strictObject({
|
|
8
|
+
format: z.literal('boring.experience'), version: z.literal(1), name: id,
|
|
9
|
+
title: z.string().max(120).optional(), source: z.enum(['fixed', 'derived', 'generated']),
|
|
10
|
+
kinds: z.record(z.string().max(160), z.number().int().min(1).max(10000)),
|
|
11
|
+
root: id, elements: z.record(id, element),
|
|
12
|
+
});
|
|
13
|
+
export function validateExperience(value, access) {
|
|
14
|
+
const parsed = descriptor.parse(value);
|
|
15
|
+
const names = Object.keys(parsed.elements);
|
|
16
|
+
if (names.length === 0 || names.length > 200 || Object.keys(parsed.kinds).length > 200)
|
|
17
|
+
throw new TypeError('Experience exceeds its element or kind limits');
|
|
18
|
+
for (const node of Object.values(parsed.elements))
|
|
19
|
+
node.props = layoutProps[node.type].parse(node.props);
|
|
20
|
+
const cells = new Map();
|
|
21
|
+
const installed = new Map(Object.keys(layoutProps).map(kind => [kind, 1]));
|
|
22
|
+
for (const cell of access.cells) {
|
|
23
|
+
layoutProps['boring/cell'].parse({ ref: cell.ref });
|
|
24
|
+
if (cells.has(cell.ref) || !/^[a-zA-Z0-9_/-]{1,160}$/.test(cell.kind) || cell.kind.startsWith('boring/') || !Number.isSafeInteger(cell.version) || cell.version < 1
|
|
25
|
+
|| cell.maxUses !== undefined && (!Number.isSafeInteger(cell.maxUses) || cell.maxUses < 1))
|
|
26
|
+
throw new TypeError('Invalid cell registration');
|
|
27
|
+
const version = installed.get(cell.kind);
|
|
28
|
+
if (version !== undefined && version !== cell.version)
|
|
29
|
+
throw new TypeError('Conflicting installed kind versions');
|
|
30
|
+
cells.set(cell.ref, cell);
|
|
31
|
+
installed.set(cell.kind, cell.version);
|
|
32
|
+
}
|
|
33
|
+
for (const [kind, version] of Object.entries(parsed.kinds))
|
|
34
|
+
if (installed.get(kind) !== version)
|
|
35
|
+
throw new TypeError('Unknown or incompatible experience kind');
|
|
36
|
+
const seen = new Set(), uses = new Map(), regions = new Set();
|
|
37
|
+
const visit = (key, depth, ancestors = []) => {
|
|
38
|
+
if (depth > 24 || seen.has(key))
|
|
39
|
+
throw new TypeError('Experience must be a bounded tree');
|
|
40
|
+
const current = parsed.elements[key];
|
|
41
|
+
if (!current || parsed.kinds[current.type] !== 1)
|
|
42
|
+
throw new TypeError('Missing element or kind version');
|
|
43
|
+
seen.add(key);
|
|
44
|
+
for (const region of ancestors)
|
|
45
|
+
if (region.kinds && !region.kinds.includes(current.type))
|
|
46
|
+
throw new TypeError('Region kind is not allowed');
|
|
47
|
+
let childrenRegions = ancestors;
|
|
48
|
+
const before = seen.size;
|
|
49
|
+
if (current.type === 'boring/generated') {
|
|
50
|
+
const region = layoutProps['boring/generated'].parse(current.props);
|
|
51
|
+
if (regions.has(region.region) || new Set(region.candidates).size !== region.candidates.length || new Set(region.regenerate).size !== region.regenerate.length || region.kinds && new Set(region.kinds).size !== region.kinds.length)
|
|
52
|
+
throw new TypeError('Invalid region registration');
|
|
53
|
+
if (region.kinds?.some(kind => !Object.hasOwn(parsed.kinds, kind)))
|
|
54
|
+
throw new TypeError('Undeclared region kind');
|
|
55
|
+
regions.add(region.region);
|
|
56
|
+
childrenRegions = [...ancestors, region];
|
|
57
|
+
}
|
|
58
|
+
if (current.type === 'boring/cell') {
|
|
59
|
+
const { ref } = layoutProps['boring/cell'].parse(current.props);
|
|
60
|
+
const cell = cells.get(ref);
|
|
61
|
+
if (!cell || parsed.kinds[cell.kind] !== cell.version || access.canView(ref) !== true)
|
|
62
|
+
throw new TypeError('Cell unavailable');
|
|
63
|
+
for (const region of ancestors)
|
|
64
|
+
if (!region.candidates.includes(ref) && !region.candidates.includes(cell.kind)
|
|
65
|
+
|| region.kinds && !region.kinds.includes(cell.kind))
|
|
66
|
+
throw new TypeError('Region cell is not allowed');
|
|
67
|
+
const count = (uses.get(ref) ?? 0) + 1;
|
|
68
|
+
uses.set(ref, count);
|
|
69
|
+
if (current.children.length || count > (cell.maxUses ?? 1))
|
|
70
|
+
throw new TypeError('Cell placement exceeds its registration');
|
|
71
|
+
}
|
|
72
|
+
for (const child of current.children)
|
|
73
|
+
visit(child, depth + 1, childrenRegions);
|
|
74
|
+
if (current.type === 'boring/generated' && seen.size - before > layoutProps['boring/generated'].parse(current.props).maxElements)
|
|
75
|
+
throw new TypeError('Region exceeds its element budget');
|
|
76
|
+
for (const value of Object.values(current.props))
|
|
77
|
+
if (Array.isArray(value))
|
|
78
|
+
Object.freeze(value);
|
|
79
|
+
Object.freeze(current.props);
|
|
80
|
+
Object.freeze(current.children);
|
|
81
|
+
Object.freeze(current);
|
|
82
|
+
};
|
|
83
|
+
visit(parsed.root, 0);
|
|
84
|
+
if (seen.size !== names.length || !experienceCatalog.validate({ root: parsed.root, elements: parsed.elements }).success)
|
|
85
|
+
throw new TypeError('Invalid experience structure');
|
|
86
|
+
Object.freeze(parsed.kinds);
|
|
87
|
+
Object.freeze(parsed.elements);
|
|
88
|
+
return Object.freeze(parsed);
|
|
89
|
+
}
|
|
90
|
+
function prepareComposition(options, access) {
|
|
91
|
+
const definition = compositionDefinition.parse(options.definition), limits = compositionLimits.parse(options.limits);
|
|
92
|
+
if (!Object.hasOwn(definition.intents, options.intent) || options.candidates.length > 128)
|
|
93
|
+
throw new TypeError('Invalid composition input');
|
|
94
|
+
const prompt = definition.intents[options.intent];
|
|
95
|
+
if (!prompt)
|
|
96
|
+
throw new TypeError('Composition intent is required');
|
|
97
|
+
const inputs = z.array(compositionCandidate).max(128).parse(options.candidates);
|
|
98
|
+
const installed = new Map(access.cells.map(cell => [cell.ref, cell]));
|
|
99
|
+
const metadata = new Map(definition.kinds.map(kind => [kind.kind, kind]));
|
|
100
|
+
if (metadata.size !== definition.kinds.length)
|
|
101
|
+
throw new TypeError('Duplicate composition kind');
|
|
102
|
+
const layouts = z.array(z.enum(['boring/stack', 'boring/row', 'boring/grid'])).max(3).parse(options.layouts ?? ['boring/stack', 'boring/row', 'boring/grid']);
|
|
103
|
+
if (new Set(layouts).size !== layouts.length)
|
|
104
|
+
throw new TypeError('Duplicate layout kind');
|
|
105
|
+
const markers = new Map(), resources = new Map(), refs = new Set();
|
|
106
|
+
const candidates = [
|
|
107
|
+
{ id: 'layout_stack', description: 'Vertical layout', element: { type: 'boring/stack', props: { gap: 'medium' } }, maxUses: limits.maxElements },
|
|
108
|
+
{ id: 'layout_row', description: 'Wrapping horizontal layout', element: { type: 'boring/row', props: { gap: 'medium' } }, maxUses: limits.maxElements },
|
|
109
|
+
{ id: 'layout_grid', description: 'Two-column layout', element: { type: 'boring/grid', props: { gap: 'medium', columns: 2 } }, maxUses: limits.maxElements },
|
|
110
|
+
];
|
|
111
|
+
candidates.splice(0, candidates.length, ...candidates.filter(candidate => layouts.some(kind => kind === candidate.element.type)));
|
|
112
|
+
for (const [index, candidate] of inputs.entries()) {
|
|
113
|
+
const cell = installed.get(candidate.ref);
|
|
114
|
+
const registration = cell && metadata.get(cell.kind);
|
|
115
|
+
if (!cell || !registration || refs.has(cell.ref) || access.canView(cell.ref) !== true)
|
|
116
|
+
throw new TypeError('Composition cell unavailable');
|
|
117
|
+
refs.add(cell.ref);
|
|
118
|
+
const flags = [];
|
|
119
|
+
for (const [key, value] of Object.entries(candidate.metadata)) {
|
|
120
|
+
if (!Object.hasOwn(registration.metadata, key) || !registration.metadata[key]?.includes(value))
|
|
121
|
+
throw new TypeError('Unregistered composition metadata');
|
|
122
|
+
flags.push(`${key}: ${value}`);
|
|
123
|
+
}
|
|
124
|
+
const marker = `composition/c${index}`;
|
|
125
|
+
markers.set(marker, cell);
|
|
126
|
+
let resource;
|
|
127
|
+
if (candidate.resource !== undefined) {
|
|
128
|
+
resource = resources.get(candidate.resource) ?? `resource_${resources.size}`;
|
|
129
|
+
resources.set(candidate.resource, resource);
|
|
130
|
+
}
|
|
131
|
+
candidates.push({ id: `candidate_${index}`, description: [registration.description, ...flags].join('; '),
|
|
132
|
+
element: { type: 'boring/cell', props: { ref: marker } }, root: candidate.root ?? false,
|
|
133
|
+
maxUses: Math.min(cell.maxUses ?? 1, limits.maxElements), ...(resource === undefined ? {} : { resource }),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (!candidates.some(candidate => candidate.root !== false))
|
|
137
|
+
throw new TypeError('Composition has no root candidate');
|
|
138
|
+
validateExperience({ format: 'boring.experience', version: 1, name: definition.name, source: 'generated',
|
|
139
|
+
kinds: { 'boring/stack': 1 }, root: 'root', elements: { root: { type: 'boring/stack', props: {}, children: [] } },
|
|
140
|
+
}, access);
|
|
141
|
+
return { definition, limits, prompt, candidates, markers, access };
|
|
142
|
+
}
|
|
143
|
+
function rebindComposition(spec, prepared) {
|
|
144
|
+
const kinds = new Map(), elements = new Map();
|
|
145
|
+
for (const [key, value] of Object.entries(spec.elements)) {
|
|
146
|
+
const parsed = element.parse(value);
|
|
147
|
+
kinds.set(parsed.type, 1);
|
|
148
|
+
if (parsed.type === 'boring/cell') {
|
|
149
|
+
const marker = layoutProps['boring/cell'].parse(parsed.props).ref, cell = prepared.markers.get(marker);
|
|
150
|
+
if (!cell)
|
|
151
|
+
throw new TypeError('Unknown composition marker');
|
|
152
|
+
kinds.set(cell.kind, cell.version);
|
|
153
|
+
parsed.props = { ref: cell.ref };
|
|
154
|
+
}
|
|
155
|
+
elements.set(key, parsed);
|
|
156
|
+
}
|
|
157
|
+
return validateExperience({ format: 'boring.experience', version: 1, name: prepared.definition.name,
|
|
158
|
+
...(prepared.definition.title === undefined ? {} : { title: prepared.definition.title }),
|
|
159
|
+
source: 'generated', kinds: Object.fromEntries(kinds), root: spec.root, elements: Object.fromEntries(elements),
|
|
160
|
+
}, prepared.access);
|
|
161
|
+
}
|
|
162
|
+
export async function* composeExperience(options) {
|
|
163
|
+
const compositionId = newCompositionId();
|
|
164
|
+
const { signal, evaluate, canView } = options;
|
|
165
|
+
const access = { cells: options.cells.map(cell => ({ ...cell })), canView };
|
|
166
|
+
const initial = descriptor.safeParse(options.fallback);
|
|
167
|
+
let sequence = 0;
|
|
168
|
+
const fallback = () => {
|
|
169
|
+
try {
|
|
170
|
+
return initial.success ? validateExperience(initial.data, access) : null;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
let prepared;
|
|
177
|
+
try {
|
|
178
|
+
signal.throwIfAborted();
|
|
179
|
+
prepared = prepareComposition(options, access);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
prepared = undefined;
|
|
183
|
+
}
|
|
184
|
+
yield Object.freeze({ compositionId, sequence: sequence++, kind: 'default', descriptor: fallback(), reason: 'pending' });
|
|
185
|
+
if (!prepared) {
|
|
186
|
+
yield Object.freeze({ compositionId, sequence: sequence++, kind: 'default', descriptor: fallback(), reason: signal.aborted ? 'cancelled' : 'unavailable' });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
signal.throwIfAborted();
|
|
191
|
+
const evaluateVisible = request => {
|
|
192
|
+
signal.throwIfAborted();
|
|
193
|
+
for (const cell of prepared.markers.values())
|
|
194
|
+
if (canView(cell.ref) !== true)
|
|
195
|
+
throw new TypeError('Composition cell unavailable');
|
|
196
|
+
signal.throwIfAborted();
|
|
197
|
+
return evaluate(request);
|
|
198
|
+
};
|
|
199
|
+
for await (const event of experimental_composeSpec({ catalog: experienceCatalog, candidates: prepared.candidates, prompt: prepared.prompt,
|
|
200
|
+
evaluate: evaluateVisible, signal, maxElements: prepared.limits.maxElements,
|
|
201
|
+
maxDepth: prepared.limits.maxDepth, maxSteps: prepared.limits.maxEvaluations,
|
|
202
|
+
})) {
|
|
203
|
+
signal.throwIfAborted();
|
|
204
|
+
if (event.type === 'complete' && (event.stopReason !== 'finish' || !event.spec)) {
|
|
205
|
+
yield Object.freeze({ compositionId, sequence: sequence++, kind: 'default', descriptor: fallback(), reason: event.stopReason === 'limit' ? 'limit' : 'unavailable' });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (!event.spec)
|
|
209
|
+
throw new TypeError('Missing composed descriptor');
|
|
210
|
+
const descriptor = rebindComposition(event.spec, prepared);
|
|
211
|
+
signal.throwIfAborted();
|
|
212
|
+
yield Object.freeze({ compositionId, sequence: sequence++, kind: event.type === 'complete' ? 'final' : 'partial', descriptor });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
yield Object.freeze({ compositionId, sequence: sequence++, kind: 'default', descriptor: fallback(), reason: signal.aborted ? 'cancelled' : 'unavailable' });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Opaque per-composition id (32 hex characters). The headless experience entries must load without `@hachej/boring-files`
|
|
4
|
+
* (README, `npm run test:experience-consumer`), so they do not import `randomUUID` from `@hachej/boring-files/platform`;
|
|
5
|
+
* `crypto.getRandomValues` exists in every context, including plain-HTTP origins, so no secure-context fallback is needed.
|
|
6
|
+
*/
|
|
7
|
+
export declare function compositionId(): string;
|
|
8
|
+
export declare const compositionDefinition: z.ZodObject<{
|
|
9
|
+
name: z.ZodString;
|
|
10
|
+
title: z.ZodOptional<z.ZodString>;
|
|
11
|
+
intents: z.ZodRecord<z.ZodString, z.ZodString>;
|
|
12
|
+
kinds: z.ZodArray<z.ZodObject<{
|
|
13
|
+
kind: z.ZodString;
|
|
14
|
+
description: z.ZodString;
|
|
15
|
+
metadata: z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>;
|
|
16
|
+
}, z.core.$strip>>;
|
|
17
|
+
}, z.core.$strip>;
|
|
18
|
+
export declare const compositionCandidate: z.ZodObject<{
|
|
19
|
+
ref: z.ZodString;
|
|
20
|
+
metadata: z.ZodRecord<z.ZodString, z.ZodString>;
|
|
21
|
+
root: z.ZodOptional<z.ZodBoolean>;
|
|
22
|
+
resource: z.ZodOptional<z.ZodString>;
|
|
23
|
+
}, z.core.$strip>;
|
|
24
|
+
export declare const compositionLimits: z.ZodObject<{
|
|
25
|
+
maxElements: z.ZodNumber;
|
|
26
|
+
maxDepth: z.ZodNumber;
|
|
27
|
+
maxEvaluations: z.ZodNumber;
|
|
28
|
+
}, z.core.$strip>;
|
|
29
|
+
//# sourceMappingURL=experience-composition-schema.d.ts.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { layoutProps, experienceIdentifier as id } from './experience-catalog.js';
|
|
3
|
+
/**
|
|
4
|
+
* Opaque per-composition id (32 hex characters). The headless experience entries must load without `@hachej/boring-files`
|
|
5
|
+
* (README, `npm run test:experience-consumer`), so they do not import `randomUUID` from `@hachej/boring-files/platform`;
|
|
6
|
+
* `crypto.getRandomValues` exists in every context, including plain-HTTP origins, so no secure-context fallback is needed.
|
|
7
|
+
*/
|
|
8
|
+
export function compositionId() {
|
|
9
|
+
return Array.from(globalThis.crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, '0')).join('');
|
|
10
|
+
}
|
|
11
|
+
const boundedRecord = (value, limit) => z.record(id, value).refine(record => Object.keys(record).length <= limit);
|
|
12
|
+
export const compositionDefinition = z.object({
|
|
13
|
+
name: id, title: z.string().max(120).optional(), intents: boundedRecord(z.string().min(1).max(1200), 32),
|
|
14
|
+
kinds: z.array(z.object({ kind: z.string().min(1).max(160), description: z.string().min(1).max(500),
|
|
15
|
+
metadata: boundedRecord(z.array(z.string().min(1).max(80)).min(1).max(32), 16),
|
|
16
|
+
})).max(128),
|
|
17
|
+
});
|
|
18
|
+
export const compositionCandidate = z.object({
|
|
19
|
+
ref: layoutProps['boring/cell'].shape.ref, metadata: boundedRecord(z.string().max(80), 16),
|
|
20
|
+
root: z.boolean().optional(), resource: z.string().min(1).max(512).optional(),
|
|
21
|
+
});
|
|
22
|
+
export const compositionLimits = z.object({ maxElements: z.number().int().min(1).max(200), maxDepth: z.number().int().min(1).max(24), maxEvaluations: z.number().int().min(1).max(32) });
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { RenderedExperienceCell } from './experience-renderer.js';
|
|
2
|
+
import type { ExperienceAccess } from './experience-compose.js';
|
|
3
|
+
import type { ExperienceDocumentController, ExperienceRegionRequest } from './experience-document.js';
|
|
4
|
+
export interface ExperienceDocumentProps {
|
|
5
|
+
readonly controller: ExperienceDocumentController;
|
|
6
|
+
readonly cells: readonly RenderedExperienceCell[];
|
|
7
|
+
readonly canView: ExperienceAccess['canView'];
|
|
8
|
+
readonly onRegenerate?: (request: ExperienceRegionRequest) => void | Promise<void>;
|
|
9
|
+
readonly title?: string;
|
|
10
|
+
readonly className?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function ExperienceDocument(props: ExperienceDocumentProps): import("react").JSX.Element;
|
|
13
|
+
//# sourceMappingURL=experience-document-viewer.d.ts.map
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { TextDraftControls } from './text-draft-controls.js';
|
|
4
|
+
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
5
|
+
import { ExperienceRenderer } from './experience-renderer.js';
|
|
6
|
+
import { layoutProps } from './experience-catalog.js';
|
|
7
|
+
function MountedDocument({ controller, cells, canView, onRegenerate, title = 'Experience layout', className }) {
|
|
8
|
+
const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
|
|
9
|
+
const [error, setError] = useState();
|
|
10
|
+
const active = useRef(true);
|
|
11
|
+
useLayoutEffect(() => { active.current = true; return () => { active.current = false; }; }, []);
|
|
12
|
+
const pending = state.save.kind === 'pending';
|
|
13
|
+
const unknown = state.save.kind === 'settled' && state.save.result.kind === 'unknown';
|
|
14
|
+
const disposed = state.lifecycle === 'disposed';
|
|
15
|
+
const proposal = state.proposal;
|
|
16
|
+
const regions = Object.values(state.descriptor?.elements ?? {}).flatMap(element => element.type === 'boring/generated'
|
|
17
|
+
? [layoutProps['boring/generated'].parse(element.props)] : []);
|
|
18
|
+
const perform = (action) => {
|
|
19
|
+
setError(undefined);
|
|
20
|
+
void Promise.resolve().then(action).then(result => {
|
|
21
|
+
if (active.current && (result.kind === 'denied' || result.kind === 'unavailable'))
|
|
22
|
+
setError(result.reason);
|
|
23
|
+
}).catch(() => { if (active.current)
|
|
24
|
+
setError('The layout operation failed. The draft has been retained.'); });
|
|
25
|
+
};
|
|
26
|
+
const status = disposed ? 'Viewer disposed' : state.readOnly ? 'Read only' : pending ? 'Keeping selected layout' : unknown ? 'Keep unconfirmed. Reconcile before keeping again.'
|
|
27
|
+
: state.save.kind === 'settled' && state.save.result.kind !== 'saved' ? state.save.result.kind === 'partial' ? 'Keep unconfirmed' : state.save.result.reason
|
|
28
|
+
: state.dirty ? 'Layout not kept' : 'Layout kept';
|
|
29
|
+
return _jsxs("section", { "data-boring": "experience-document", className: className, "aria-label": title, children: [_jsxs("header", { children: [_jsx("strong", { children: title }), _jsx("span", { role: "status", children: status })] }), _jsxs("div", { role: "toolbar", "aria-label": "Layout document controls", children: [_jsx("button", { type: "button", disabled: disposed || state.readOnly || !state.dirty || !state.descriptor || pending || unknown, onClick: () => { const selection = controller.actions.selection(); perform(() => controller.flush(selection)); }, children: "Keep this layout" }), _jsx("button", { type: "button", disabled: disposed || pending || unknown, onClick: () => perform(controller.actions.refresh), children: "Refresh layout" }), _jsx("button", { type: "button", disabled: disposed || !unknown, onClick: () => perform(controller.actions.reconcile), children: "Reconcile keep" }), _jsx("button", { type: "button", disabled: disposed || pending || unknown || state.remote === null, onClick: () => perform(controller.actions.discardToRemote), children: "Discard local layout" }), state.pin && _jsx("button", { type: "button", disabled: disposed || state.readOnly || pending || unknown, onClick: () => {
|
|
30
|
+
const pin = state.pin;
|
|
31
|
+
if (pin)
|
|
32
|
+
perform(() => controller.actions.pin(pin.selection));
|
|
33
|
+
}, children: "Pin this region" }), regions.filter(region => region.regenerate.includes('request')).map(region => _jsxs("button", { type: "button", disabled: disposed || state.readOnly || !onRegenerate, onClick: () => {
|
|
34
|
+
const result = controller.actions.beginRegion(controller.actions.selection(), region.region, 'request');
|
|
35
|
+
if (result.kind !== 'applied') {
|
|
36
|
+
setError('reason' in result ? result.reason : 'Region request was not started');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
setError(undefined);
|
|
40
|
+
void Promise.resolve().then(() => onRegenerate?.(result.value)).catch(() => {
|
|
41
|
+
if (active.current)
|
|
42
|
+
setError('The region could not be regenerated. The current layout has been retained.');
|
|
43
|
+
});
|
|
44
|
+
}, children: ["Regenerate ", region.region] }, region.region))] }), _jsx(TextDraftControls, { recovery: state.recovery, actions: controller.actions, readOnly: state.readOnly, blocked: state.lifecycle !== 'active' || state.save.kind === 'pending' || (state.save.kind === 'settled' && state.save.result.kind === 'unknown') }), proposal && _jsxs("div", { role: "group", "aria-label": proposal.kind === 'region' ? 'Proposed region' : 'Proposed layout', children: [_jsx("button", { type: "button", disabled: disposed || state.readOnly, onClick: () => {
|
|
45
|
+
const result = controller.actions.adopt(proposal.id);
|
|
46
|
+
setError(result.kind === 'applied' ? undefined : 'reason' in result ? result.reason : 'Layout was not adopted');
|
|
47
|
+
}, children: proposal.kind === 'region' ? 'Use proposed region' : 'Use proposed layout' }), _jsx("button", { type: "button", disabled: disposed, onClick: () => { controller.actions.reject(proposal.id); setError(undefined); }, children: proposal.kind === 'region' ? 'Dismiss proposed region' : 'Dismiss proposed layout' })] }), (error || state.problem) && _jsx("p", { role: "alert", children: error ?? state.problem }), state.descriptor ? _jsx("section", { "data-boring": "experience", "aria-label": state.descriptor.title ?? state.descriptor.name, children: _jsx(ExperienceRenderer, { descriptor: state.descriptor, cells: cells, canView: canView }) }) : _jsx("p", { children: "No layout selected" })] });
|
|
48
|
+
}
|
|
49
|
+
export function ExperienceDocument(props) {
|
|
50
|
+
const [mount, setMount] = useState({ controller: props.controller, sequence: 0 });
|
|
51
|
+
if (mount.controller !== props.controller)
|
|
52
|
+
setMount({ controller: props.controller, sequence: mount.sequence + 1 });
|
|
53
|
+
return _jsx(MountedDocument, { ...props }, mount.sequence);
|
|
54
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ReadResult, ResourceLocator, ResourceSnapshot } from '@hachej/boring-files';
|
|
2
|
+
import type { PresentationResult } from './contracts.js';
|
|
3
|
+
import type { EditableViewerController, SaveResult, SaveSelection } from './resources.js';
|
|
4
|
+
import type { TextBufferOptions, TextBufferState } from './text-buffer.js';
|
|
5
|
+
import type { ExperienceAccess, ExperienceDescriptor } from './experience-compose.js';
|
|
6
|
+
import type { ExperienceRegionTrigger } from './experience-regions.js';
|
|
7
|
+
import type { TextDraftActions, TextDraftOptions } from './text-draft-types.js';
|
|
8
|
+
export type ExperienceDocumentSource = {
|
|
9
|
+
readonly kind: 'saved';
|
|
10
|
+
readonly snapshot: ResourceSnapshot;
|
|
11
|
+
} | {
|
|
12
|
+
readonly kind: 'new';
|
|
13
|
+
readonly target: ResourceLocator;
|
|
14
|
+
readonly descriptor?: unknown;
|
|
15
|
+
};
|
|
16
|
+
export interface ExperienceDocumentOptions extends Pick<TextBufferOptions, 'identity' | 'instanceId' | 'epoch' | 'client' | 'readOnly' | 'onListenerError'>, ExperienceAccess {
|
|
17
|
+
readonly source: ExperienceDocumentSource;
|
|
18
|
+
readonly drafts?: TextDraftOptions;
|
|
19
|
+
readonly validateDocument?: (descriptor: Readonly<ExperienceDescriptor>) => void;
|
|
20
|
+
}
|
|
21
|
+
interface Proposal {
|
|
22
|
+
readonly id: string;
|
|
23
|
+
readonly base: SaveSelection;
|
|
24
|
+
readonly descriptor: ExperienceDescriptor;
|
|
25
|
+
}
|
|
26
|
+
export type ExperienceProposal = Proposal & ({
|
|
27
|
+
readonly kind: 'layout';
|
|
28
|
+
} | {
|
|
29
|
+
readonly kind: 'region';
|
|
30
|
+
readonly region: string;
|
|
31
|
+
readonly requestId: string;
|
|
32
|
+
});
|
|
33
|
+
export interface ExperienceRegionRequest {
|
|
34
|
+
readonly id: string;
|
|
35
|
+
readonly region: string;
|
|
36
|
+
readonly trigger: ExperienceRegionTrigger;
|
|
37
|
+
readonly base: SaveSelection;
|
|
38
|
+
}
|
|
39
|
+
export interface ExperienceDocumentState extends TextBufferState {
|
|
40
|
+
readonly descriptor: ExperienceDescriptor | null;
|
|
41
|
+
readonly problem: string | null;
|
|
42
|
+
readonly proposal: ExperienceProposal | null;
|
|
43
|
+
readonly pin: {
|
|
44
|
+
readonly region: string;
|
|
45
|
+
readonly selection: SaveSelection;
|
|
46
|
+
} | null;
|
|
47
|
+
}
|
|
48
|
+
type Subject = SaveSelection['target']['subject'];
|
|
49
|
+
export interface ExperienceDocumentActions extends TextDraftActions {
|
|
50
|
+
readonly selection: () => SaveSelection;
|
|
51
|
+
readonly propose: (base: SaveSelection, descriptor: unknown) => PresentationResult<void, Subject>;
|
|
52
|
+
readonly beginRegion: (base: SaveSelection, region: string, trigger: ExperienceRegionTrigger) => PresentationResult<ExperienceRegionRequest, Subject>;
|
|
53
|
+
readonly proposeRegion: (request: ExperienceRegionRequest, descriptor: unknown) => PresentationResult<void, Subject>;
|
|
54
|
+
readonly pin: (selection: SaveSelection, signal?: AbortSignal) => Promise<SaveResult>;
|
|
55
|
+
readonly adopt: (proposalId: string) => PresentationResult<SaveSelection, Subject>;
|
|
56
|
+
readonly reject: (proposalId: string) => void;
|
|
57
|
+
readonly refresh: () => Promise<ReadResult>;
|
|
58
|
+
readonly discardToRemote: () => Promise<ReadResult>;
|
|
59
|
+
readonly reconcile: () => Promise<SaveResult>;
|
|
60
|
+
}
|
|
61
|
+
export type ExperienceDocumentController = EditableViewerController<ExperienceDocumentState, ExperienceDocumentActions, undefined>;
|
|
62
|
+
export declare function createExperienceDocumentController(options: ExperienceDocumentOptions): ExperienceDocumentController;
|
|
63
|
+
export {};
|
|
64
|
+
//# sourceMappingURL=experience-document.d.ts.map
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { createTextBuffer, freeze, sameBase } from './text-buffer.js';
|
|
2
|
+
import { validateExperience } from './experience-compose.js';
|
|
3
|
+
import { experienceRegion, assertRegionReplacement } from './experience-region-tree.js';
|
|
4
|
+
import { replaceRegionText } from './experience-region-text.js';
|
|
5
|
+
import { randomUUID } from '@hachej/boring-files/platform';
|
|
6
|
+
export function createExperienceDocumentController(options) {
|
|
7
|
+
const parse = (text) => {
|
|
8
|
+
const descriptor = validateExperience(JSON.parse(text), options);
|
|
9
|
+
if (descriptor.source !== 'fixed')
|
|
10
|
+
throw new TypeError('Saved experience must be fixed');
|
|
11
|
+
options.validateDocument?.(freeze(descriptor));
|
|
12
|
+
return descriptor;
|
|
13
|
+
};
|
|
14
|
+
const fixedText = (descriptor) => JSON.stringify({ ...descriptor, source: 'fixed' }) + '\n';
|
|
15
|
+
const source = options.source.kind === 'saved' ? options.source : {
|
|
16
|
+
kind: 'new', target: options.source.target,
|
|
17
|
+
text: options.source.descriptor === undefined ? '' : fixedText(validateExperience(options.source.descriptor, options)),
|
|
18
|
+
};
|
|
19
|
+
const { drafts, ...bufferOptions } = options;
|
|
20
|
+
const buffer = createTextBuffer({ ...bufferOptions, ...(drafts ? { drafts: { ...drafts, format: 'experience/v1', validateText: (text) => { parse(text); } } } : {}), source, mediaType: 'application/json',
|
|
21
|
+
readText: snapshot => {
|
|
22
|
+
if (snapshot.mediaType !== 'application/json')
|
|
23
|
+
throw new TypeError('Expected an experience JSON document');
|
|
24
|
+
const text = new TextDecoder('utf-8', { fatal: true }).decode(snapshot.bytes);
|
|
25
|
+
parse(text);
|
|
26
|
+
return text;
|
|
27
|
+
} });
|
|
28
|
+
const decoded = (text) => {
|
|
29
|
+
if (!text)
|
|
30
|
+
return { descriptor: null, problem: null };
|
|
31
|
+
try {
|
|
32
|
+
return { descriptor: parse(text), problem: null };
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return { descriptor: null, problem: 'The selected layout is unavailable.' };
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
let state = freeze({ ...buffer.getSnapshot(), ...decoded(buffer.getSnapshot().text), proposal: null, pin: null });
|
|
39
|
+
let pinOrigin = null;
|
|
40
|
+
let requestSequence = 0;
|
|
41
|
+
const requests = new Map();
|
|
42
|
+
const listeners = new Set();
|
|
43
|
+
const update = (next) => {
|
|
44
|
+
state = freeze(next);
|
|
45
|
+
for (const listener of [...listeners]) {
|
|
46
|
+
try {
|
|
47
|
+
listener();
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
queueMicrotask(() => { if (options.onListenerError)
|
|
51
|
+
options.onListenerError(error);
|
|
52
|
+
else
|
|
53
|
+
throw error; });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const unsubscribe = buffer.subscribe(() => {
|
|
58
|
+
const next = buffer.getSnapshot();
|
|
59
|
+
const view = next.text === state.text ? { descriptor: state.descriptor, problem: state.problem } : decoded(next.text);
|
|
60
|
+
if (buffer.getSnapshot() === next)
|
|
61
|
+
update({ ...next, ...view, proposal: state.proposal,
|
|
62
|
+
pin: pinOrigin?.version === next.bufferVersion && next.dirty && next.lifecycle === 'active' ? { region: pinOrigin.region, selection: buffer.selection() } : null });
|
|
63
|
+
});
|
|
64
|
+
const selected = (selection) => {
|
|
65
|
+
const current = buffer.selection().target, supplied = selection.target;
|
|
66
|
+
return current.instanceId === supplied.instanceId && current.epoch === supplied.epoch
|
|
67
|
+
&& current.subject.scopeId === supplied.subject.scopeId && current.subject.bufferVersion === supplied.subject.bufferVersion
|
|
68
|
+
&& sameBase(current.subject.base, supplied.subject.base);
|
|
69
|
+
};
|
|
70
|
+
const propose = (supplied, value) => {
|
|
71
|
+
const base = freeze(structuredClone(supplied));
|
|
72
|
+
if (state.lifecycle === 'disposed')
|
|
73
|
+
return { kind: 'unavailable', reason: 'Viewer is disposed' };
|
|
74
|
+
if (state.readOnly)
|
|
75
|
+
return { kind: 'denied', reason: 'Layout document is read-only' };
|
|
76
|
+
if (!selected(base))
|
|
77
|
+
return { kind: 'stale', reason: 'Proposal base has changed' };
|
|
78
|
+
const descriptor = validateExperience(value, options);
|
|
79
|
+
if (!selected(base) || buffer.getSnapshot().lifecycle === 'disposed')
|
|
80
|
+
return { kind: 'stale', reason: 'Proposal base changed during validation' };
|
|
81
|
+
const proposal = freeze({ kind: 'layout', id: randomUUID(), base: structuredClone(base), descriptor });
|
|
82
|
+
update({ ...state, proposal });
|
|
83
|
+
return { kind: 'proposed', proposalId: proposal.id, base: proposal.base.target };
|
|
84
|
+
};
|
|
85
|
+
const currentRequest = (request) => {
|
|
86
|
+
const stored = requests.get(request.region);
|
|
87
|
+
return stored?.id === request.id && stored.trigger === request.trigger && selected(stored.base) && selected(request.base);
|
|
88
|
+
};
|
|
89
|
+
const beginRegion = (base, region, trigger) => {
|
|
90
|
+
if (state.lifecycle === 'disposed')
|
|
91
|
+
return { kind: 'unavailable', reason: 'Viewer is disposed' };
|
|
92
|
+
if (state.readOnly)
|
|
93
|
+
return { kind: 'denied', reason: 'Layout document is read-only' };
|
|
94
|
+
if (!selected(base))
|
|
95
|
+
return { kind: 'stale', reason: 'Region base has changed' };
|
|
96
|
+
const request = freeze({ id: randomUUID(), base: structuredClone(base), region, trigger });
|
|
97
|
+
const sequence = ++requestSequence;
|
|
98
|
+
try {
|
|
99
|
+
const descriptor = parse(buffer.getSnapshot().text), found = experienceRegion(descriptor, region);
|
|
100
|
+
if (!found.props.regenerate.includes(trigger))
|
|
101
|
+
return { kind: 'denied', reason: 'Region trigger is not enabled' };
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return { kind: 'denied', reason: 'Region is unavailable' };
|
|
105
|
+
}
|
|
106
|
+
if (sequence !== requestSequence || !selected(request.base) || buffer.getSnapshot().lifecycle === 'disposed')
|
|
107
|
+
return { kind: 'stale', reason: 'Region changed during validation' };
|
|
108
|
+
for (const [name, previous] of requests)
|
|
109
|
+
if (!selected(previous.base))
|
|
110
|
+
requests.delete(name);
|
|
111
|
+
requests.set(region, request);
|
|
112
|
+
update({ ...state, proposal: state.proposal?.kind === 'region' && state.proposal.region === region ? null : state.proposal });
|
|
113
|
+
return { kind: 'applied', value: request };
|
|
114
|
+
};
|
|
115
|
+
const proposeRegion = (supplied, value) => {
|
|
116
|
+
if (state.lifecycle === 'disposed')
|
|
117
|
+
return { kind: 'unavailable', reason: 'Viewer is disposed' };
|
|
118
|
+
if (state.readOnly)
|
|
119
|
+
return { kind: 'denied', reason: 'Layout document is read-only' };
|
|
120
|
+
if (!currentRequest(supplied))
|
|
121
|
+
return { kind: 'stale', reason: 'Region request or base has changed' };
|
|
122
|
+
const request = requests.get(supplied.region);
|
|
123
|
+
if (!request)
|
|
124
|
+
return { kind: 'stale', reason: 'Region request has changed' };
|
|
125
|
+
let descriptor;
|
|
126
|
+
try {
|
|
127
|
+
const base = parse(buffer.getSnapshot().text);
|
|
128
|
+
descriptor = validateExperience(value, options);
|
|
129
|
+
assertRegionReplacement(base, descriptor, request.region);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return { kind: 'denied', reason: 'Proposed region is unavailable' };
|
|
133
|
+
}
|
|
134
|
+
if (!currentRequest(request) || buffer.getSnapshot().lifecycle === 'disposed')
|
|
135
|
+
return { kind: 'stale', reason: 'Region changed during validation' };
|
|
136
|
+
const proposal = freeze({ kind: 'region', id: randomUUID(), base: structuredClone(request.base), descriptor, region: request.region, requestId: request.id });
|
|
137
|
+
update({ ...state, proposal });
|
|
138
|
+
return { kind: 'proposed', proposalId: proposal.id, base: proposal.base.target };
|
|
139
|
+
};
|
|
140
|
+
const adopt = proposalId => {
|
|
141
|
+
if (state.lifecycle === 'disposed')
|
|
142
|
+
return { kind: 'unavailable', reason: 'Viewer is disposed' };
|
|
143
|
+
if (state.readOnly)
|
|
144
|
+
return { kind: 'denied', reason: 'Layout document is read-only' };
|
|
145
|
+
const proposal = state.proposal;
|
|
146
|
+
if (!proposal || proposal.id !== proposalId || !selected(proposal.base)
|
|
147
|
+
|| proposal.kind === 'region' && requests.get(proposal.region)?.id !== proposal.requestId)
|
|
148
|
+
return { kind: 'stale', reason: 'Offered layout or its base has changed' };
|
|
149
|
+
let text;
|
|
150
|
+
const before = buffer.getSnapshot();
|
|
151
|
+
try {
|
|
152
|
+
const descriptor = validateExperience(proposal.descriptor, options);
|
|
153
|
+
text = proposal.kind === 'region' ? replaceRegionText(before.text, parse(before.text), descriptor, proposal.region) : fixedText(descriptor);
|
|
154
|
+
if (proposal.kind === 'region')
|
|
155
|
+
JSON.parse(text);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return { kind: 'denied', reason: 'Offered layout is no longer available' };
|
|
159
|
+
}
|
|
160
|
+
if (!selected(proposal.base) || state.proposal?.id !== proposalId || buffer.getSnapshot().lifecycle === 'disposed'
|
|
161
|
+
|| proposal.kind === 'region' && requests.get(proposal.region)?.id !== proposal.requestId)
|
|
162
|
+
return { kind: 'stale', reason: 'Offered layout changed during validation' };
|
|
163
|
+
const selection = freeze({ target: { ...proposal.base.target, subject: { ...proposal.base.target.subject, bufferVersion: proposal.base.target.subject.bufferVersion + 1 } } });
|
|
164
|
+
pinOrigin = proposal.kind === 'region' && (!before.dirty || pinOrigin?.region === proposal.region && pinOrigin.version === before.bufferVersion)
|
|
165
|
+
? { region: proposal.region, version: selection.target.subject.bufferVersion } : null;
|
|
166
|
+
state = freeze({ ...state, proposal: null });
|
|
167
|
+
buffer.edit(text, true);
|
|
168
|
+
return { kind: 'applied', value: selection };
|
|
169
|
+
};
|
|
170
|
+
const flush = (supplied, signal) => {
|
|
171
|
+
const selection = freeze(structuredClone(supplied));
|
|
172
|
+
const before = buffer.getSnapshot();
|
|
173
|
+
if (before.lifecycle === 'disposed' || before.readOnly || !selected(selection) || before.save.kind === 'pending'
|
|
174
|
+
|| before.save.kind === 'settled' && before.save.result.kind === 'unknown')
|
|
175
|
+
return buffer.flush(selection, signal);
|
|
176
|
+
try {
|
|
177
|
+
parse(before.text);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
if (buffer.getSnapshot() !== before)
|
|
181
|
+
return buffer.flush(selection, signal);
|
|
182
|
+
return Promise.resolve({ kind: 'denied', reason: 'The selected layout is unavailable for keeping' });
|
|
183
|
+
}
|
|
184
|
+
return buffer.flush(selection, signal);
|
|
185
|
+
};
|
|
186
|
+
const pin = (selection, signal) => {
|
|
187
|
+
const current = buffer.getSnapshot();
|
|
188
|
+
if (current.lifecycle === 'disposed' || current.readOnly || !selected(selection) || current.save.kind === 'pending'
|
|
189
|
+
|| current.save.kind === 'settled' && current.save.result.kind === 'unknown')
|
|
190
|
+
return buffer.flush(selection, signal);
|
|
191
|
+
if (!state.pin || pinOrigin?.version !== current.bufferVersion)
|
|
192
|
+
return Promise.resolve({ kind: 'denied', reason: 'Pin requires a draft containing only this region change' });
|
|
193
|
+
return flush(selection, signal);
|
|
194
|
+
};
|
|
195
|
+
const refresh = async (discard) => {
|
|
196
|
+
const result = await buffer.refresh(discard);
|
|
197
|
+
if (result.kind === 'available' && state.problem && state.lifecycle !== 'disposed') {
|
|
198
|
+
const current = buffer.getSnapshot(), view = decoded(current.text);
|
|
199
|
+
if (buffer.getSnapshot() === current)
|
|
200
|
+
update({ ...current, ...view, proposal: state.proposal, pin: state.pin });
|
|
201
|
+
}
|
|
202
|
+
return result;
|
|
203
|
+
};
|
|
204
|
+
return {
|
|
205
|
+
getSnapshot: () => state,
|
|
206
|
+
subscribe: listener => { if (state.lifecycle === 'disposed')
|
|
207
|
+
return () => { }; listeners.add(listener); return () => { listeners.delete(listener); }; },
|
|
208
|
+
actions: {
|
|
209
|
+
selection: buffer.selection, propose, adopt, beginRegion, proposeRegion, pin,
|
|
210
|
+
checkDrafts: buffer.checkDrafts, checkpointDraft: buffer.checkpointDraft, discardDraft: buffer.discardDraft,
|
|
211
|
+
restoreDraft: async (choice) => {
|
|
212
|
+
const proposal = state.proposal, origin = pinOrigin, sequence = requestSequence;
|
|
213
|
+
const priorRequests = new Map(requests);
|
|
214
|
+
const result = await buffer.restoreDraft(choice);
|
|
215
|
+
if (result.kind === 'restored') {
|
|
216
|
+
if (pinOrigin === origin)
|
|
217
|
+
pinOrigin = null;
|
|
218
|
+
for (const [id, request] of priorRequests)
|
|
219
|
+
if (requests.get(id) === request)
|
|
220
|
+
requests.delete(id);
|
|
221
|
+
if (requestSequence === sequence)
|
|
222
|
+
requestSequence++;
|
|
223
|
+
update({ ...state, proposal: state.proposal === proposal ? null : state.proposal, pin: pinOrigin ? state.pin : null });
|
|
224
|
+
}
|
|
225
|
+
return result;
|
|
226
|
+
},
|
|
227
|
+
reject: id => { if (state.lifecycle !== 'disposed' && state.proposal?.id === id)
|
|
228
|
+
update({ ...state, proposal: null }); },
|
|
229
|
+
refresh: () => refresh(false), discardToRemote: () => refresh(true), reconcile: buffer.reconcile,
|
|
230
|
+
},
|
|
231
|
+
tools: undefined, flush,
|
|
232
|
+
dispose: () => { buffer.dispose(); unsubscribe(); listeners.clear(); requests.clear(); },
|
|
233
|
+
};
|
|
234
|
+
}
|