@jarenjs/studio 0.83.3 → 0.84.3

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 (90) hide show
  1. package/README.md +21 -6
  2. package/contracts/data.contract.json +243 -0
  3. package/dist/types/component/data/actions.d.ts +234 -0
  4. package/dist/types/component/data/index.d.ts +9 -0
  5. package/dist/types/component/data/mount.d.ts +87 -0
  6. package/dist/types/component/data/project-widget.d.ts +27 -0
  7. package/dist/types/component/data/viewmodel.d.ts +85 -0
  8. package/dist/types/component/data/views.d.ts +148 -0
  9. package/dist/types/component/document.d.ts +82 -0
  10. package/dist/types/component/flow/actions.d.ts +708 -0
  11. package/dist/types/component/flow/index.d.ts +7 -0
  12. package/dist/types/component/flow/mount.d.ts +66 -0
  13. package/dist/types/component/flow/project-widget.d.ts +8 -0
  14. package/dist/types/component/flow/runtime.d.ts +57 -0
  15. package/dist/types/component/flow/views.d.ts +242 -0
  16. package/dist/types/component/host.d.ts +2 -2
  17. package/dist/types/component/index.d.ts +21 -15
  18. package/dist/types/component/mount.d.ts +51 -0
  19. package/dist/types/component/project-actions.d.ts +302 -0
  20. package/dist/types/component/project-controller.d.ts +81 -0
  21. package/dist/types/component/project-state.d.ts +1 -0
  22. package/dist/types/component/project.d.ts +327 -0
  23. package/dist/types/component/shared/host-widget.d.ts +24 -0
  24. package/dist/types/component/shared/memo.d.ts +12 -0
  25. package/dist/types/component/shared/nodes.d.ts +92 -0
  26. package/dist/types/component/shared/schema-options.d.ts +28 -0
  27. package/dist/types/component/shared/studio-kit.d.ts +78 -0
  28. package/dist/types/component/shared/ui.d.ts +136 -0
  29. package/dist/types/component/view.d.ts +6 -6
  30. package/dist/types/data/boot-stages.d.ts +97 -0
  31. package/dist/types/data/browser-worker.d.ts +22 -0
  32. package/dist/types/data/contract.d.ts +9 -0
  33. package/dist/types/data/editor.d.ts +81 -0
  34. package/dist/types/data/handlers.d.ts +130 -0
  35. package/dist/types/data/host.d.ts +8 -0
  36. package/dist/types/data/project-worker.d.ts +8 -0
  37. package/dist/types/data/runtime.d.ts +46 -0
  38. package/dist/types/data/state.d.ts +33 -0
  39. package/dist/types/data/storage.d.ts +26 -0
  40. package/dist/types/data/transport.d.ts +69 -0
  41. package/dist/types/flow-document.d.ts +19 -0
  42. package/dist/types/flow-editor.d.ts +88 -0
  43. package/docs/EDITORS.md +158 -0
  44. package/docs/PROJECT-FORMAT.md +1 -11
  45. package/package.json +32 -15
  46. package/src/component/data/actions.js +138 -0
  47. package/src/component/data/index.js +10 -0
  48. package/src/component/data/mount.js +63 -0
  49. package/src/component/data/project-widget.js +164 -0
  50. package/src/component/data/viewmodel.js +185 -0
  51. package/src/component/data/views.js +241 -0
  52. package/src/component/document.js +344 -0
  53. package/src/component/flow/actions.js +331 -0
  54. package/src/component/flow/index.js +8 -0
  55. package/src/component/flow/mount.js +53 -0
  56. package/src/component/flow/project-widget.js +44 -0
  57. package/src/component/flow/runtime.js +481 -0
  58. package/src/component/flow/views.js +196 -0
  59. package/src/component/host.js +2 -2
  60. package/src/component/index.js +19 -9
  61. package/src/component/mount.js +43 -0
  62. package/src/component/project-actions.js +189 -0
  63. package/src/component/project-controller.js +248 -0
  64. package/src/component/project-state.js +30 -0
  65. package/src/component/project.js +308 -0
  66. package/src/component/shared/host-widget.js +35 -0
  67. package/src/component/shared/memo.js +28 -0
  68. package/src/component/shared/nodes.js +94 -0
  69. package/src/component/shared/schema-options.js +30 -0
  70. package/src/component/shared/studio-kit.js +59 -0
  71. package/src/component/shared/ui.js +134 -0
  72. package/src/data/boot-stages.js +202 -0
  73. package/src/data/browser-worker.js +247 -0
  74. package/src/data/contract.js +7 -0
  75. package/src/data/editor.js +95 -0
  76. package/src/data/handlers.js +349 -0
  77. package/src/data/host.js +8 -0
  78. package/src/data/project-worker.js +26 -0
  79. package/src/data/runtime.js +462 -0
  80. package/src/data/state.js +46 -0
  81. package/src/data/storage.js +61 -0
  82. package/src/data/transport.js +215 -0
  83. package/src/flow-document.js +24 -0
  84. package/src/flow-editor.js +98 -0
  85. package/styles/data.css +53 -0
  86. package/styles/editor.css +150 -0
  87. package/styles/flow.css +99 -0
  88. package/styles/studio.css +1 -0
  89. package/dist/types/author.d.ts +0 -27
  90. package/src/author.js +0 -55
@@ -9,7 +9,7 @@
9
9
  * is skipped.
10
10
  *
11
11
  * `reconcileBuffer` (editor buffer ↔ document): a CLEAN buffer adopts an
12
- * incoming write (share / undo / an AI edit lands); a DIRTY buffer whose
12
+ * incoming write (share / undo / an external edit lands); a DIRTY buffer whose
13
13
  * text differs from the incoming write keeps the human's text and records
14
14
  * the write as a recoverable draft — never a silent clobber, never a
15
15
  * hidden write.
@@ -38,7 +38,7 @@ export function hostPolicy(prevProject, nextProject) {
38
38
 
39
39
  /**
40
40
  * Reconcile the editor's local typing buffer against an incoming
41
- * committed text (a share/undo restore, or an AI write onto the same
41
+ * committed text (a share/undo restore, or an external write onto the same
42
42
  * file). A clean buffer adopts; a dirty buffer that already matches the
43
43
  * incoming text simply clears (the commit landed); a dirty buffer that
44
44
  * differs keeps the human's text and surfaces the incoming version as a
@@ -1,14 +1,8 @@
1
1
  //@ts-check
2
2
  /**
3
- * @file The Studio COMPONENT the IDE. Following the suite's component
4
- * convention (`createXComponent`), `createStudioComponent(options)` hands
5
- * the host the pieces it composes into the site's `@jarenjs/app` document:
6
- * the JSLT view (`rules` + `mode`), the derivation (`viewModel`), the two
7
- * hard-problem policies (`hostPolicy`, `reconcileBuffer`), and the engine
8
- * surface. The reducer `project/*` actions, the DOM stage/splitter
9
- * widgets, and the live site mount are wired at the host; the chrome and
10
- * its derivation — everything renderable without a DOM — live here and
11
- * are tested headlessly.
3
+ * The complete project editor and composable host pieces. A host can mount
4
+ * the editor directly, or compose the same actions, projections and controller
5
+ * into its application. Preview runners, templates and storage are injected.
12
6
  */
13
7
 
14
8
  import { describe, validateFile, classifyChange, parseProject } from '../index.js';
@@ -45,3 +39,19 @@ export {
45
39
  projectViewModel, projectRules, projectModes, PROJECT_MODE, PROJECT_BASE,
46
40
  hostPolicy, reconcileBuffer, editorTextarea, errorLine, KIND_BADGE,
47
41
  };
42
+
43
+ export { createStudioDocumentHost } from './document.js';
44
+ export { createProjectHost } from './project.js';
45
+ export { p, cards, table, callout, code, errorMessage, error, details, chart, article, search, more } from './shared/nodes.js';
46
+ export { tabRule, UI_RULES } from './shared/ui.js';
47
+ export { memo1 } from './shared/memo.js';
48
+ export { OUR_SCHEMA_OPTIONS } from './shared/schema-options.js';
49
+ export { createHostWidget } from './shared/host-widget.js';
50
+
51
+ export { PROJECT_ACTIONS } from './project-actions.js';
52
+ export { createProjectState } from './project-state.js';
53
+ export { createProjectController } from './project-controller.js';
54
+
55
+ export { mountStudioEditor } from './mount.js';
56
+
57
+ export { editorTextarea as sharedEditorTextarea, errorLine as editorErrorLine, paneSwitcher } from './shared/studio-kit.js';
@@ -0,0 +1,43 @@
1
+ import { createProjectFlowWidget } from './flow/project-widget.js';
2
+ //@ts-check
3
+ /** Mount the complete project chrome with the same transitions used by composed hosts. */
4
+ import { createApp, formEventFields } from '@jarenjs/app';
5
+ import { createProjectController } from './project-controller.js';
6
+ import { createProjectState } from './project-state.js';
7
+ import { PROJECT_ACTIONS } from './project-actions.js';
8
+ import { UI_RULES } from './shared/ui.js';
9
+
10
+ /**
11
+ * Execution, document rendering, seeds and private data ownership are host services.
12
+ * The returned surface publishes document operations; the app state stays private.
13
+ * @param {HTMLElement | null} node
14
+ * @param {{ project: object, host: any, projectData?: any, projectTemplate?: Function,
15
+ * templates?: any[], widgets?: Record<string, any>, schedule?: (flush: () => void) => void,
16
+ * debounceMs?: number, download?: Function, exportProject?: Function, onError?: (error: Error) => void }} options
17
+ */
18
+ export function mountStudioEditor(node, options) {
19
+ let app;
20
+ const host = options.host, component = host.projectComponent;
21
+ const controller = createProjectController({ ...options, getApp: () => app });
22
+ app = createApp({
23
+ state: { project: createProjectState(options.project), ide: { shared: null } },
24
+ actions: { ...PROJECT_ACTIONS, 'ide/shared': { patch: [{ op: 'replace', path: '/ide/shared', value: '$payload' }] } },
25
+ view: { $jslt: '0.1', modes: { ...component.modes, ui: { unmatched: 'error' } }, rules: [
26
+ { match: '$', body: { $apply: ['$.ui.project', 'project'] } }, ...component.rules, ...UI_RULES,
27
+ ] },
28
+ }, {
29
+ node, document: node?.ownerDocument, schedule: options.schedule, onError: options.onError,
30
+ eventFields: formEventFields(), effects: controller.effects,
31
+ widgets: { 'studio-flow': createProjectFlowWidget({ schedule: options.schedule }),
32
+ ...(options.projectData ? { 'studio-data': options.projectData.widget } : {}),
33
+ 'studio-stage': host.createProjectStageWidget({ schedule: options.schedule }),
34
+ 'studio-splitter': host.createProjectSplitterWidget(), ...options.widgets },
35
+ viewModel: state => ({ ui: { project: { ...component.viewModel(state), templates: options.templates ?? [] } } }),
36
+ });
37
+ controller.attach(); controller.commit(); controller.runActive();
38
+ return {
39
+ read: controller.read, validate: controller.validate, replace: controller.replace,
40
+ apply: controller.apply, run: controller.run, subscribe: controller.subscribe,
41
+ dispose() { controller.dispose(); app.destroy(); },
42
+ };
43
+ }
@@ -0,0 +1,189 @@
1
+ //@ts-check
2
+ /** Project editor transitions, shared by every host. */
3
+ export const PROJECT_ACTIONS = {
4
+ // the Project IDE (#/project, boundaries/project.js): a jaren-project
5
+ // edited as one document. The editor commits the ACTIVE file's text
6
+ // through the `project-edit` effect (it rewrites the file by name, in
7
+ // JS — an array index a patch path cannot compute); the changed
8
+ // `/project/files` feed then drives the debounced commit
9
+ // (`project/committed`) that folds in the last-good app stage.
10
+ // every keystroke publishes to the typing buffer. This is what the
11
+ // CONTROLLED editor is reasserted with: without it a render between the
12
+ // keystroke and the blur would rewrite the box with the still-stale
13
+ // committed text, clearing the browser's dirty-value flag so the commit
14
+ // never fires and the typing is lost. Cheap by construction — it touches
15
+ // no file, so nothing revalidates.
16
+ 'project/buffer-text': {
17
+ patch: [{
18
+ op: 'replace',
19
+ path: '/project/buffer',
20
+ value: { file: '$.project.active', text: '$event.value', dirty: true },
21
+ }],
22
+ },
23
+ // resolve a conflict the human's way round: drop the buffer, so the
24
+ // incoming (committed) text is what the editor shows
25
+ 'project/buffer-accept': {
26
+ patch: [{ op: 'replace', path: '/project/buffer', value: null }],
27
+ },
28
+ // the commit (blur): the buffer has served its purpose, so it clears
29
+ // here — on the HUMAN's own commit, never on `project/files-set`, which
30
+ // is also how an external write lands and must not drop a dirty buffer
31
+ 'project/route-input': { effects: [{ run: 'project-route', with: { member: 'input', value: '$event.value' } }] },
32
+ 'project/route-model': { effects: [{ run: 'project-route', with: { member: 'model', value: '$event.value' } }] },
33
+ 'project/route-collection': { effects: [{ run: 'project-route', with: { member: 'collection', value: '$event.value' } }] },
34
+ 'project/artifact-edit': { effects: [{ run: 'project-artifact-edit', with: '$payload' }] },
35
+ 'project/file-text': {
36
+ patch: [{ op: 'replace', path: '/project/buffer', value: null }],
37
+ effects: [{ run: 'project-edit', with: { text: '$event.value' } }],
38
+ },
39
+ 'project/files-set': {
40
+ patch: [
41
+ { op: 'replace', path: '/project/files', value: '$payload.files' },
42
+ { op: 'replace', path: '/project/dirty', value: true },
43
+ ],
44
+ },
45
+ // switching files drops the buffer: it belongs to the file you left
46
+ 'project/active': {
47
+ patch: [
48
+ { op: 'replace', path: '/project/active', value: '$payload' },
49
+ { op: 'replace', path: '/project/buffer', value: null },
50
+ // picking a file in the rail — or an error line in the strip — is a
51
+ // request to EDIT it, and on a phone the rail is a different pane
52
+ // from the editor. Carry the user across (invisible on desktop).
53
+ { op: 'replace', path: '/project/mobilePane', value: 'editor' },
54
+ ],
55
+ },
56
+ // the debounced boundary reports the last-good app mount + reboot
57
+ // revision (an invalid edit keeps the previous — the stage never blanks)
58
+ 'project/committed': {
59
+ patch: [
60
+ { op: 'replace', path: '/project/mount', value: '$payload.mount' },
61
+ { op: 'replace', path: '/project/revision', value: '$payload.revision' },
62
+ { op: 'replace', path: '/project/dirty', value: false },
63
+ ],
64
+ },
65
+ // explicit Run: force-commit + restart the app stage (or re-run a
66
+ // transform file). Pressing Run is a request to watch it happen, so on
67
+ // a phone the stage comes forward with it.
68
+ 'project/execute': {
69
+ patch: [{ op: 'replace', path: '/project/mobilePane', value: 'stage' }],
70
+ effects: [{ run: 'project-editor-run', with: '$payload' }],
71
+ },
72
+ 'project/run': {
73
+ patch: [{ op: 'replace', path: '/project/mobilePane', value: 'stage' }],
74
+ effects: [{ run: 'project-run' }],
75
+ },
76
+ 'project/autorun': {
77
+ patch: [
78
+ { op: 'replace', path: '/project/layout/autorun', value: { $not: '$.project.layout.autorun' } },
79
+ { op: 'replace', path: '/project/dirty', value: true },
80
+ ],
81
+ },
82
+ // a transform file's run result (render nodes) — keyed by file name
83
+ 'project/result': {
84
+ patch: [{ op: 'add', path: { $concat: ['/project/results/',
85
+ { $replace: [{ $replace: ['$payload.name', '~', '~0'] }, '/', '~1'] },
86
+ ] }, value: '$payload.result' }],
87
+ },
88
+ // file management: add (a kind from the rail select), delete (× per row),
89
+ // rename (the editor-head name field). Each rewrites the files array in
90
+ // JS (an effect), then a patch action lands the result; a name change
91
+ // resets the mount/results so the stage re-establishes cleanly.
92
+ 'project/add-file': { effects: [{ run: 'project-add', with: { kind: '$event.value' } }] },
93
+ 'project/delete': { effects: [{ run: 'project-delete', with: { name: '$payload' } }] },
94
+ // the name field's typing buffer — the sibling of `project/buffer-text`,
95
+ // and cheap the same way: it touches no file, so nothing revalidates
96
+ 'project/rename-draft': {
97
+ patch: [{
98
+ op: 'replace',
99
+ path: '/project/renameDraft',
100
+ value: { file: '$.project.active', text: '$event.value' },
101
+ }],
102
+ },
103
+ // the commit (blur or Enter), shaped like `project/file-text`: the draft
104
+ // has served its purpose so it clears, and the effect reads the EVENT —
105
+ // the value the human actually committed, with no dependence on the
106
+ // keystroke's own transaction having drained first
107
+ 'project/rename': {
108
+ patch: [{ op: 'replace', path: '/project/renameDraft', value: null }],
109
+ effects: [{ run: 'project-rename', with: { name: '$event.value' } }],
110
+ },
111
+ 'project/added': {
112
+ patch: [
113
+ { op: 'replace', path: '/project/files', value: '$payload.files' },
114
+ { op: 'replace', path: '/project/active', value: '$payload.active' },
115
+ { op: 'replace', path: '/project/dirty', value: true },
116
+ { op: 'replace', path: '/project/buffer', value: null },
117
+ ],
118
+ },
119
+ 'project/structural': {
120
+ patch: [
121
+ { op: 'replace', path: '/project/files', value: '$payload.files' },
122
+ { op: 'replace', path: '/project/active', value: '$payload.active' },
123
+ { op: 'replace', path: '/project/mount', value: null },
124
+ { op: 'replace', path: '/project/results', value: {} },
125
+ { op: 'replace', path: '/project/dirty', value: true },
126
+ { op: 'replace', path: '/project/buffer', value: null },
127
+ ],
128
+ },
129
+ // the nested app's own boot/runtime failure (the stage widget emits it)
130
+ 'project/stage-error': { patch: [{ op: 'replace', path: '/project/stageError', value: '$payload' }] },
131
+ // open a whole project (a template card, or an inbound share token)
132
+ 'project/open': {
133
+ patch: [
134
+ { op: 'replace', path: '/project/project', value: { $default: ['$payload.project', '0.1'] } },
135
+ { op: 'replace', path: '/project/name', value: '$payload.name' },
136
+ { op: 'replace', path: '/project/files', value: '$payload.files' },
137
+ { op: 'replace', path: '/project/active', value: '$payload.active' },
138
+ { op: 'replace', path: '/project/layout', value: '$payload.layout' },
139
+ { op: 'replace', path: '/project/mount', value: null },
140
+ { op: 'replace', path: '/project/revision', value: 0 },
141
+ { op: 'replace', path: '/project/dirty', value: false },
142
+ { op: 'replace', path: '/project/results', value: {} },
143
+ { op: 'replace', path: '/project/stageError', value: null },
144
+ { op: 'replace', path: '/project/buffer', value: null },
145
+ // opening a project is a request to SEE it — on a phone that means
146
+ // the stage, not the editor it happens to have activated (the same
147
+ // move `play/loaded` makes when an example is picked). Desktop
148
+ // shows every pane, so this patch is invisible there.
149
+ { op: 'replace', path: '/project/mobilePane', value: 'stage' },
150
+ ],
151
+ },
152
+ 'project/template': { effects: [{ run: 'project-template', with: { id: '$payload' } }] },
153
+ // download the designated app file's document (the Studio's takeaway)
154
+ 'project/download': { effects: [{ run: 'project-download' }] },
155
+ 'project/eject': { effects: [{ run: 'project-eject' }] },
156
+ 'project/export': { effects: [{ run: 'project-export' }] },
157
+ // the layout switcher (which grid mode) + the splitter (where the handle
158
+ // sits within that mode); the splitter widget commits on pointer-up
159
+ 'project/layout-mode': { patch: [{ op: 'replace', path: '/project/layout/mode', value: '$payload' }] },
160
+ 'project/layout-ratio': { patch: [{ op: 'replace', path: '/project/layout/ratio', value: '$payload' }] },
161
+ // the phone pane switcher (Files · Editor · Stage) — pure chrome, and
162
+ // deliberately NOT part of `layout`: `layout` is a jaren-project member
163
+ // that saves, shares and downloads with the document, and which pane a
164
+ // phone happened to be showing is not a property of the project
165
+ 'project/pane': { patch: [{ op: 'replace', path: '/project/mobilePane', value: '$payload' }] },
166
+
167
+ };
168
+
169
+ /** Compare at the serialized app transition, including a queued manual edit. */
170
+ PROJECT_ACTIONS['project/replace'] = {
171
+ $if: [
172
+ { $eq: [{
173
+ project: { $default: ['$.project.project', '0.1'] },
174
+ files: { $default: ['$.project.files', []] },
175
+ active: { $default: ['$.project.active', null] },
176
+ layout: '$.project.layout',
177
+ name: { $default: ['$.project.name', 'Untitled project'] },
178
+ }, '$payload.expected'] },
179
+ { patch: [
180
+ { op: 'replace', path: '/project/project', value: '$payload.document.project' },
181
+ { op: 'replace', path: '/project/name', value: '$payload.document.name' },
182
+ { op: 'replace', path: '/project/files', value: '$payload.document.files' },
183
+ { op: 'replace', path: '/project/active', value: '$payload.document.active' },
184
+ { op: 'replace', path: '/project/layout', value: '$payload.document.layout' },
185
+ { op: 'replace', path: '/project/dirty', value: true },
186
+ ], effects: [{ run: 'project-accepted', with: '$payload.requestId' }] },
187
+ { effects: [{ run: 'project-refused', with: '$payload.requestId' }] },
188
+ ],
189
+ };
@@ -0,0 +1,248 @@
1
+ //@ts-check
2
+ /** @typedef {{ document: any, revision: string, stageRevision: number }} ProjectSnapshot */
3
+ /** @typedef {{ ok: boolean, document?: any, revision?: string, conflict?: boolean, error?: string,
4
+ * errors?: any[], valid?: boolean, total?: number, code?: string, result?: any, stale?: boolean, requested?: boolean }} EditorReceipt */
5
+ /** Project actions, effects, change feeds and candidate publication share one controller. */
6
+ import { semanticKey } from '@jarenjs/core/object';
7
+ import { applyJSONPatch } from '@jarenjs/json/patch';
8
+ import { renameProjectFile, writeProjectArtifact, resolveProjectFile, fileSkeleton } from '../index.js';
9
+
10
+ /** @param {{ getApp: () => any, host: any, projectData?: any, projectTemplate?: Function, download?: Function, exportProject?: Function, debounceMs?: number }} env */
11
+ export function createProjectController(env) {
12
+ const { projectSnapshot, projectAppFile, commitProject, runProjectFile } = env.host;
13
+ const projectData = env.projectData ?? { dispose() {}, sync() {} };
14
+ let stopped = false, detach = null, timer = null, requestId = 0;
15
+ const requests = new Map(), listeners = new Set();
16
+ const copy = value => JSON.parse(JSON.stringify(value));
17
+ /** @returns {ProjectSnapshot} */
18
+ function read() {
19
+ const document = projectSnapshot(env.getApp().getState().project);
20
+ return { document: copy(document), revision: semanticKey(document), stageRevision: env.getApp().getState().project.revision };
21
+ }
22
+ function validate(candidate) {
23
+ try {
24
+ const { name = 'Untitled project', ...envelope } = copy(candidate);
25
+ if (typeof name !== 'string') throw new TypeError('The project name must be a string.');
26
+ const document = env.host.projectComponent.parseProject(envelope);
27
+ const description = env.host.projectComponent.describe(document);
28
+ const errors = description.files.flatMap(file => file.errors.map(error => ({ ...error, file: file.name })));
29
+ return { valid: errors.length === 0, errors, total: errors.length, document: projectSnapshot({ ...document, name }) };
30
+ }
31
+ catch (error) { return { valid: false, errors: [{ code: error.code ?? 'JS0001', message: error.message, docPath: error.docPath ?? '' }], total: 1 }; }
32
+ }
33
+ function settle(id, ok) {
34
+ const resolve = requests.get(id);
35
+ if (!resolve) return;
36
+ requests.delete(id);
37
+ resolve({ ok, ...read(), ...(ok ? {} : { conflict: true, error: 'The project changed before the candidate could be applied.' }) });
38
+ }
39
+ /** Publish only a validated candidate against the caller's observed revision.
40
+ * @param {any} candidate
41
+ * @param {{ expectedRevision?: string }} [options]
42
+
43
+ * @returns {Promise<EditorReceipt>}
44
+ */
45
+ function replace(candidate, { expectedRevision } = {}) {
46
+ if (stopped) return Promise.resolve({ ok: false, error: 'The project editor is disposed.' });
47
+ const previous = read();
48
+ if (typeof expectedRevision !== 'string' || previous.revision !== expectedRevision)
49
+ return Promise.resolve({ ok: false, conflict: true, error: 'The project revision changed.', ...previous });
50
+ const checked = validate(candidate);
51
+ if (!checked.valid) return Promise.resolve({ ok: false, ...checked });
52
+ return new Promise(resolve => {
53
+ const id = ++requestId; requests.set(id, resolve);
54
+ try { env.getApp().dispatch('project/replace', { requestId: id, expected: previous.document, document: checked.document }); }
55
+ catch (error) { requests.delete(id); resolve({ ok: false, error: error.message }); }
56
+ });
57
+ }
58
+ /** @param {Parameters<typeof applyJSONPatch>[1]} patch @param {{ expectedRevision?: string }} [options]
59
+ * @returns {Promise<EditorReceipt>}
60
+ */
61
+ function apply(patch, options) {
62
+ try { return replace(applyJSONPatch(read().document, patch), options); }
63
+ catch (error) { return Promise.resolve({ ok: false, error: error.message, code: error.code }); }
64
+ }
65
+ /** Run a file through the shared host. Computational runs return their settled
66
+ * render result; interactive app files acknowledge their stage restart.
67
+ * `restart: false` commits an interactive app through its normal hot-update
68
+ * path, preserving local state when only document state changed.
69
+ * @param {string} [name] @param {{ restart?: boolean }} [options]
70
+ * @returns {Promise<EditorReceipt> | { error: string }} */
71
+ function run(name = env.getApp().getState().project.active, options = {}) {
72
+ if (stopped) return { error: 'The project editor is disposed.' };
73
+ const app = env.getApp();
74
+ if (!app.getState().project.files.some(file => file.name === name)) return { error: `Unknown project file '${name}'.` };
75
+ const before = read();
76
+ return new Promise(resolve => {
77
+ const id = ++requestId; requests.set(id, resolve);
78
+ app.dispatch('project/execute', { requestId: id, name, expectedRevision: before.revision, restart: options.restart !== false });
79
+ });
80
+ }
81
+ /** @param {(snapshot: ProjectSnapshot) => void} listener */
82
+ function subscribe(listener) {
83
+ if (stopped) return () => {};
84
+ listeners.add(listener); return () => listeners.delete(listener);
85
+ }
86
+ function commit() {
87
+ const app = env.getApp(), state = app?.getState().project;
88
+ if (!stopped && state) app.dispatch('project/committed', commitProject(state));
89
+ }
90
+ function runActive() {
91
+ const app = env.getApp(), state = app?.getState().project;
92
+ if (stopped || !state) return;
93
+ const file = state.files.find(f => f.name === state.active);
94
+ if (file && !file.model && ['query', 'jslt', 'schema', 'contract'].includes(file.kind))
95
+ app.dispatch('project/result', { name: state.active, result: runProjectFile(state, state.active) });
96
+ }
97
+ function attach() {
98
+ if (detach || stopped) return;
99
+ let revision = read().revision;
100
+ detach = env.getApp().subscribe((state, changes) => {
101
+ if (stopped || (changes !== null && !changes.some(path => path === '/project' || path.startsWith('/project/')))) return;
102
+ const current = read();
103
+ if (revision !== current.revision) { revision = current.revision; for (const listener of listeners) listener(current); }
104
+ if (changes === null) { projectData.sync(state.project, true); return; }
105
+ if (changes.some(path => path === '/project/files' || path.startsWith('/project/files/')))
106
+ projectData.sync(state.project, changes.includes('/project/project'));
107
+ if (!changes.some(path => path === '/project/files' || path.startsWith('/project/files/') || path === '/project/active' || path === '/project/layout/autorun')) return;
108
+ const run = () => { if (!stopped && env.getApp().getState().project.layout.autorun !== false) { commit(); runActive(); } };
109
+ if ((env.debounceMs ?? 250) === 0) run();
110
+ else { clearTimeout(timer); timer = setTimeout(run, env.debounceMs ?? 250); }
111
+ });
112
+ }
113
+ function dispose() {
114
+ if (stopped) return;
115
+ stopped = true; clearTimeout(timer); detach?.(); detach = null; projectData.dispose(); listeners.clear();
116
+ for (const resolve of requests.values()) resolve({ ok: false, error: 'The project editor is disposed.' });
117
+ requests.clear();
118
+ }
119
+ const effects = {
120
+ 'project-accepted': id => settle(id, true),
121
+ 'project-refused': id => settle(id, false),
122
+ 'project-editor-run': async (props, dispatch) => {
123
+ const finish = outcome => { const resolve = requests.get(props.requestId); requests.delete(props.requestId); resolve?.(outcome); };
124
+ if (stopped) { finish({ ok: false, error: 'The project editor is disposed.' }); return; }
125
+ if (read().revision !== props.expectedRevision) { finish({ ok: false, conflict: true, ...read() }); return; }
126
+ const state = env.getApp().getState().project, file = state.files.find(f => f.name === props.name);
127
+ dispatch('project/active', props.name);
128
+ dispatch('project/pane', 'stage');
129
+ if (file?.kind === 'app') {
130
+ if (props.restart === false) dispatch('project/committed', commitProject(state));
131
+ else dispatch('project/run');
132
+ await Promise.resolve(); finish({ ok: true, requested: true, ...read() }); return;
133
+ }
134
+ try {
135
+ const result = await runProjectFile(state, props.name, { model: projectData.execute });
136
+ if (stopped) return;
137
+ if (result === null) { finish({ ok: false, error: `File '${props.name}' has no result runner in this host.` }); return; }
138
+ if (env.getApp().getState().project.files !== state.files) { finish({ ok: false, stale: true, result: copy(result) }); return; }
139
+ dispatch('project/result', { name: props.name, result });
140
+ finish({ ok: true, ...read(), result: copy(result) });
141
+ }
142
+ catch (error) { finish({ ok: false, error: error.message, code: error.code }); }
143
+ },
144
+ // the app-document download: the project's designated app file, under
145
+ // the name Studio downloads always carried
146
+ 'project-download': (props, dispatch) => {
147
+ const file = projectAppFile(env.getApp().getState().project);
148
+ if (file === null) { dispatch('ide/shared', 'this project has no app document'); return; }
149
+ let doc;
150
+ try { doc = resolveProjectFile(env.getApp().getState().project, file.name).doc; }
151
+ catch { dispatch('ide/shared', 'the app document is not valid JSON'); return; }
152
+ const saved = env.download?.('jaren-studio-app.json', JSON.stringify(doc, null, 2));
153
+ dispatch('ide/shared', saved === true ? 'document downloaded' : 'download unavailable here');
154
+ },
155
+ 'project-eject': async (props, dispatch) => {
156
+ try {
157
+ dispatch('ide/shared', 'Preparing offline project…');
158
+ const saved = await env.exportProject?.(projectSnapshot(env.getApp().getState().project));
159
+ dispatch('ide/shared', saved ? 'offline project downloaded' : 'offline export unavailable here');
160
+ }
161
+ catch (error) { dispatch('ide/shared', error.message); }
162
+ },
163
+ 'project-export': (props, dispatch) => {
164
+ const snapshot = projectSnapshot(env.getApp().getState().project);
165
+ const saved = env.download?.('jaren-project.json', JSON.stringify(snapshot, null, 2));
166
+ dispatch('ide/shared', saved === true ? 'project downloaded' : 'download unavailable here');
167
+ },
168
+
169
+ // the Project IDE: the editor commits the ACTIVE file's text (rewriting
170
+ // it by name — an array index a patch path cannot compute); explicit
171
+ // Run force-restarts the app stage; a template card opens a project.
172
+ 'project-route': (props, dispatch) => {
173
+ const p = env.getApp().getState().project;
174
+ const files = p.files.map((f) => {
175
+ if (f.name !== p.active) return f;
176
+ const next = { ...f };
177
+ if (props.value === '') delete next[props.member];
178
+ else next[props.member] = props.value;
179
+ if (props.member === 'model') delete next.collection;
180
+ return next;
181
+ });
182
+ dispatch('project/files-set', { files });
183
+ },
184
+ 'project-artifact-edit': (props, dispatch) => {
185
+ const p = env.getApp().getState().project;
186
+ let files;
187
+ try { files = writeProjectArtifact(p, props.name, props.doc); }
188
+ catch (error) { dispatch('project/stage-error', error.message); return; }
189
+ dispatch('project/files-set', { files });
190
+ },
191
+ 'project-edit': (props, dispatch) => {
192
+ const p = env.getApp().getState().project;
193
+ const files = p.files.map((f) => (f.name === p.active ? { ...f, text: props.text } : f));
194
+ dispatch('project/files-set', { files });
195
+ },
196
+ 'project-run': Object.assign((props, dispatch) => {
197
+ const state = env.getApp().getState().project;
198
+ const active = state.files.find((f) => f.name === state.active);
199
+ const commit = commitProject(state);
200
+ const fragment = commit.mount?.sourceFiles?.includes(state.active) && commit.mount.name !== state.active;
201
+ // a transform / schema / contract file re-runs; an app file force-restarts
202
+ if (active !== undefined && !fragment && !active.model && (active.kind === 'query' || active.kind === 'jslt' || active.kind === 'schema'
203
+ || active.kind === 'contract')) {
204
+ dispatch('project/result', { name: state.active, result: runProjectFile(state, state.active) });
205
+ return;
206
+ }
207
+ const mount = commit.mount === null ? null : { ...commit.mount, revision: commit.mount.revision + 1 };
208
+ dispatch('project/committed', { mount, revision: mount === null ? commit.revision : mount.revision });
209
+ }, { dispose }),
210
+ 'project-template': (props, dispatch) => {
211
+ const template = env.projectTemplate?.(props.id);
212
+ if (template === undefined) return;
213
+ dispatch('project/open', template);
214
+ // commit NOW, computed from the template itself: the debounced edit
215
+ // loop alone leaves a race where an edit inside the debounce window
216
+ // supersedes the opening commit — the "last good frame" then never
217
+ // existed and an invalid edit blanks the stage
218
+ dispatch('project/committed', commitProject(template));
219
+ },
220
+ // file management (the files array is an array — index-by-name lives in
221
+ // JS here, then a patch action lands the result)
222
+ 'project-add': (props, dispatch) => {
223
+ const text = fileSkeleton(props.kind);
224
+ if (text === null || text === undefined) return;
225
+ const p = env.getApp().getState().project;
226
+ let n = 1;
227
+ let name = `${props.kind}-${n}.${props.kind}`;
228
+ while (p.files.some((f) => f.name === name)) { n += 1; name = `${props.kind}-${n}.${props.kind}`; }
229
+ dispatch('project/added', { files: [...p.files, { name, kind: props.kind, text }], active: name });
230
+ },
231
+ 'project-delete': (props, dispatch) => {
232
+ const p = env.getApp().getState().project;
233
+ if (p.files.length <= 1) return; // never delete the last file
234
+ const files = p.files.filter((f) => f.name !== props.name);
235
+ if (files.length === p.files.length) return; // no such file
236
+ const active = p.active === props.name ? files[0].name : p.active;
237
+ dispatch('project/structural', { files, active });
238
+ },
239
+ 'project-rename': (props, dispatch) => {
240
+ const p = env.getApp().getState().project;
241
+ const next = String(props.name ?? '').trim();
242
+ if (next === '' || next === p.active || p.files.some((f) => f.name === next)) return;
243
+ const files = renameProjectFile(p.files, p.active, next);
244
+ dispatch('project/structural', { files, active: next });
245
+ },
246
+ };
247
+ return { effects, attach, dispose, commit, runActive, read, validate, replace, apply, run, subscribe };
248
+ }
@@ -0,0 +1,30 @@
1
+ //@ts-check
2
+ /** One independent project editor state over a host-supplied document. */
3
+ import { parseProject } from '../project.js';
4
+
5
+ export function createProjectState(project) {
6
+ const { name = 'Untitled project', ...envelope } = JSON.parse(JSON.stringify(project));
7
+ project = { ...parseProject(envelope), name };
8
+ return {
9
+ ...project,
10
+ mount: null, // last-good { name, doc, revision } for the active app
11
+ revision: 0, // reboot key — bumps only on a structural change
12
+ dirty: false, // the editor has uncommitted text
13
+ // the editor's typing buffer ({ file, text, dirty } or null): every
14
+ // keystroke lands here, the commit lands on blur. It is what the
15
+ // controlled textarea is reasserted with, so a render mid-edit cannot
16
+ // overwrite the user; a write arriving on the same file while it is
17
+ // dirty raises a conflict instead of clobbering either side.
18
+ buffer: null,
19
+ // the file-name field's own buffer ({ file, text } or null). Separate
20
+ // from `buffer` because its commit is a RENAME: publishing every
21
+ // keystroke straight through would rename the file once per letter.
22
+ renameDraft: null,
23
+ results: {}, // file name -> a run result (query/jslt: later order)
24
+ stageError: null, // the nested app's own boot/runtime failure, if any
25
+ // the phone layout: which single pane shows (files | editor | stage).
26
+ // Pure chrome — the panes stay mounted, CSS picks one, and the
27
+ // project/* re-run feed ignores this path by construction.
28
+ mobilePane: 'editor',
29
+ };
30
+ }