@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.
- package/README.md +21 -6
- package/contracts/data.contract.json +243 -0
- package/dist/types/component/data/actions.d.ts +234 -0
- package/dist/types/component/data/index.d.ts +9 -0
- package/dist/types/component/data/mount.d.ts +87 -0
- package/dist/types/component/data/project-widget.d.ts +27 -0
- package/dist/types/component/data/viewmodel.d.ts +85 -0
- package/dist/types/component/data/views.d.ts +148 -0
- package/dist/types/component/document.d.ts +82 -0
- package/dist/types/component/flow/actions.d.ts +708 -0
- package/dist/types/component/flow/index.d.ts +7 -0
- package/dist/types/component/flow/mount.d.ts +66 -0
- package/dist/types/component/flow/project-widget.d.ts +8 -0
- package/dist/types/component/flow/runtime.d.ts +57 -0
- package/dist/types/component/flow/views.d.ts +242 -0
- package/dist/types/component/host.d.ts +2 -2
- package/dist/types/component/index.d.ts +21 -15
- package/dist/types/component/mount.d.ts +51 -0
- package/dist/types/component/project-actions.d.ts +302 -0
- package/dist/types/component/project-controller.d.ts +81 -0
- package/dist/types/component/project-state.d.ts +1 -0
- package/dist/types/component/project.d.ts +327 -0
- package/dist/types/component/shared/host-widget.d.ts +24 -0
- package/dist/types/component/shared/memo.d.ts +12 -0
- package/dist/types/component/shared/nodes.d.ts +92 -0
- package/dist/types/component/shared/schema-options.d.ts +28 -0
- package/dist/types/component/shared/studio-kit.d.ts +78 -0
- package/dist/types/component/shared/ui.d.ts +136 -0
- package/dist/types/component/view.d.ts +6 -6
- package/dist/types/data/boot-stages.d.ts +97 -0
- package/dist/types/data/browser-worker.d.ts +22 -0
- package/dist/types/data/contract.d.ts +9 -0
- package/dist/types/data/editor.d.ts +81 -0
- package/dist/types/data/handlers.d.ts +130 -0
- package/dist/types/data/host.d.ts +8 -0
- package/dist/types/data/project-worker.d.ts +8 -0
- package/dist/types/data/runtime.d.ts +46 -0
- package/dist/types/data/state.d.ts +33 -0
- package/dist/types/data/storage.d.ts +26 -0
- package/dist/types/data/transport.d.ts +69 -0
- package/dist/types/flow-document.d.ts +19 -0
- package/dist/types/flow-editor.d.ts +88 -0
- package/docs/EDITORS.md +158 -0
- package/docs/PROJECT-FORMAT.md +1 -11
- package/package.json +32 -15
- package/src/component/data/actions.js +138 -0
- package/src/component/data/index.js +10 -0
- package/src/component/data/mount.js +63 -0
- package/src/component/data/project-widget.js +164 -0
- package/src/component/data/viewmodel.js +185 -0
- package/src/component/data/views.js +241 -0
- package/src/component/document.js +344 -0
- package/src/component/flow/actions.js +331 -0
- package/src/component/flow/index.js +8 -0
- package/src/component/flow/mount.js +53 -0
- package/src/component/flow/project-widget.js +44 -0
- package/src/component/flow/runtime.js +481 -0
- package/src/component/flow/views.js +196 -0
- package/src/component/host.js +2 -2
- package/src/component/index.js +19 -9
- package/src/component/mount.js +43 -0
- package/src/component/project-actions.js +189 -0
- package/src/component/project-controller.js +248 -0
- package/src/component/project-state.js +30 -0
- package/src/component/project.js +308 -0
- package/src/component/shared/host-widget.js +35 -0
- package/src/component/shared/memo.js +28 -0
- package/src/component/shared/nodes.js +94 -0
- package/src/component/shared/schema-options.js +30 -0
- package/src/component/shared/studio-kit.js +59 -0
- package/src/component/shared/ui.js +134 -0
- package/src/data/boot-stages.js +202 -0
- package/src/data/browser-worker.js +247 -0
- package/src/data/contract.js +7 -0
- package/src/data/editor.js +95 -0
- package/src/data/handlers.js +349 -0
- package/src/data/host.js +8 -0
- package/src/data/project-worker.js +26 -0
- package/src/data/runtime.js +462 -0
- package/src/data/state.js +46 -0
- package/src/data/storage.js +61 -0
- package/src/data/transport.js +215 -0
- package/src/flow-document.js +24 -0
- package/src/flow-editor.js +98 -0
- package/styles/data.css +53 -0
- package/styles/editor.css +150 -0
- package/styles/flow.css +99 -0
- package/styles/studio.css +1 -0
- package/dist/types/author.d.ts +0 -27
- package/src/author.js +0 -55
package/src/component/host.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
package/src/component/index.js
CHANGED
|
@@ -1,14 +1,8 @@
|
|
|
1
1
|
//@ts-check
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
+
}
|