dsh-ptc-plus 0.3.2 → 0.4.1
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.en.md +65 -78
- package/README.md +65 -78
- package/assets/ptc-plus-bindings-en.png +0 -0
- package/assets/ptc-plus-bindings-zh.png +0 -0
- package/assets/ptc-plus-repair-en.png +0 -0
- package/assets/ptc-plus-repair-zh.png +0 -0
- package/assets/ptc-plus-repl-workspace-en.png +0 -0
- package/assets/ptc-plus-repl-workspace-zh.png +0 -0
- package/assets/ptc-plus-settings-en.png +0 -0
- package/assets/ptc-plus-settings-zh.png +0 -0
- package/client.js +30596 -742
- package/compiler-amaro.cjs +5 -0
- package/compiler-amaro.cjs.map +6 -0
- package/compiler-asset-124f80c1bba92ff5fe79cfec6e8ad518f476de4a42514cae3919538887f510e8.txt +1 -0
- package/compiler-assets.json +3 -0
- package/compiler-core.cjs +554 -0
- package/compiler-core.cjs.map +6 -0
- package/compiler-platform.cjs +2 -0
- package/compiler-platform.cjs.map +6 -0
- package/compiler-typescript.cjs +388 -0
- package/compiler-typescript.cjs.map +6 -0
- package/docs/README.md +6 -0
- package/docs/adr/0004-declare-program-bindings-at-the-host.md +1 -1
- package/docs/adr/0006-rewrite-module-syntax-with-an-ast.md +5 -3
- package/docs/adr/0007-separate-worker-transport-from-session-semantics.md +1 -1
- package/docs/adr/0009-bound-expensive-acceptance-default.md +2 -0
- package/docs/adr/0010-session-log-derived-recovery-tips.md +59 -5
- package/docs/adr/0011-isolate-the-local-recovery-tip-provider.md +1 -1
- package/docs/adr/0012-preserve-mixed-destructuring-initialization.md +3 -1
- package/docs/adr/0013-isolate-repl-redeclaration-convenience.md +3 -1
- package/docs/adr/0014-persist-cell-rewrite-policy.md +6 -2
- package/docs/adr/0015-preserve-source-positions-through-rewrites.md +1 -1
- package/docs/adr/0016-archive-resolved-review-findings.md +1 -1
- package/docs/adr/0017-track-the-latest-dsh-public-surface.md +49 -0
- package/docs/adr/0018-use-versioned-fixture-for-ab-benchmark.md +3 -3
- package/docs/adr/0019-plugin-settings-and-kill-switch.md +5 -3
- package/docs/adr/0020-optional-cordis-tools-in-ptc-mode.md +2 -2
- package/docs/adr/0021-separate-host-bootstrap-from-session-runtime.md +12 -0
- package/docs/adr/0023-global-user-bindings.md +104 -0
- package/docs/adr/0024-repl-console-observation.md +33 -0
- package/docs/adr/0025-use-versioned-logical-binding-identities.md +119 -0
- package/docs/adr/0026-persist-the-delivery-verdict-against-the-verified-candidate.md +21 -0
- package/docs/adr/0027-run-the-session-kernel-in-a-killable-helper-process.md +37 -0
- package/docs/adr/0028-attach-to-the-host-ptc-execution-seam.md +103 -0
- package/docs/adr/0029-keep-plugin-unavailability-out-of-the-host-agent-lifecycle.md +27 -0
- package/docs/adr/0030-serve-both-typert-codec-generations-from-one-codec.md +27 -0
- package/docs/architecture.md +73 -21
- package/docs/binding-review-placement-design.md +200 -0
- package/docs/capability-projection.md +1 -1
- package/docs/client-ui.md +95 -19
- package/docs/durability-design.md +25 -10
- package/docs/evaluation.md +78 -6
- package/docs/installation.md +41 -6
- package/docs/publishing.md +5 -3
- package/docs/runtime-reference.md +72 -25
- package/docs/semantic-validation.md +29 -0
- package/docs/stateful-binding-updates-proposal.md +755 -0
- package/docs/user-bindings.en.md +64 -0
- package/docs/user-bindings.md +64 -0
- package/docs/value-wire.md +3 -3
- package/docs/verification.md +57 -0
- package/index.js +369 -92
- package/internal/ast-traversal.js +5 -2
- package/internal/binding-descriptors.js +1 -0
- package/internal/binding-pattern-traversal.js +25 -0
- package/internal/binding-pattern.js +26 -29
- package/internal/binding-source-analysis.js +391 -0
- package/internal/binding-update-policy.js +66 -0
- package/internal/bounded-worker-round.js +56 -0
- package/internal/callable-definition-environment.js +42 -0
- package/internal/callable-source-catalog.js +129 -0
- package/internal/callable-source-encoding.js +47 -0
- package/internal/callable-source-facts.js +459 -0
- package/internal/callable-source-format.js +2 -0
- package/internal/callable-source-lowering.js +123 -0
- package/internal/cell-analysis-contract.js +25 -0
- package/internal/cell-analysis.js +240 -290
- package/internal/cell-completion.js +15 -0
- package/internal/cell-error.js +7 -0
- package/internal/cell-parser.js +66 -0
- package/internal/cell-rewriter.js +3 -677
- package/internal/commonjs-export-evidence.js +63 -0
- package/internal/commonjs-source-evidence.js +72 -0
- package/internal/compiler-amaro.cjs +2 -0
- package/internal/compiler-compression.js +12 -0
- package/internal/compiler-data.js +58 -0
- package/internal/compiler-descriptors.js +54 -0
- package/internal/compiler-dynamic-cache.js +48 -0
- package/internal/compiler-entry.js +35 -0
- package/internal/compiler-generated-names.js +17 -0
- package/internal/compiler-intrinsics.js +84 -0
- package/internal/compiler-module-links.js +34 -0
- package/internal/compiler-operations.js +61 -0
- package/internal/compiler-platform-factory.js +84 -0
- package/internal/compiler-platform.js +136 -0
- package/internal/compiler-record-roles.js +53 -0
- package/internal/compiler-region-output.js +259 -0
- package/internal/compiler-resource-lowering.js +103 -0
- package/internal/compiler-scope-facts.js +115 -0
- package/internal/compiler-service.js +109 -0
- package/internal/compiler-source-cache.js +32 -0
- package/internal/compiler-source-pieces.js +93 -0
- package/internal/compiler-source-regions.js +132 -0
- package/internal/compiler-storage-source.js +54 -0
- package/internal/compiler-text.js +15 -0
- package/internal/compiler-typescript.cjs +2 -0
- package/internal/config-spec.js +83 -27
- package/internal/cordis-tools-owner.js +130 -21
- package/internal/diagnostic.js +27 -1
- package/internal/direct-surface-owner.js +76 -23
- package/internal/dynamic-binding-evidence.js +16 -0
- package/internal/dynamic-environment-compiler.js +779 -0
- package/internal/dynamic-environment-integration.js +87 -0
- package/internal/dynamic-environment-runtime.js +362 -0
- package/internal/dynamic-native-calls.js +150 -0
- package/internal/dynamic-native-runtime.js +248 -0
- package/internal/dynamic-scope-analysis.js +90 -0
- package/internal/edit-transport-owner.js +27 -2
- package/internal/execution-seam-compat.js +193 -0
- package/internal/failure-reporting.js +118 -18
- package/internal/host-rpc.js +66 -0
- package/internal/isolated-worker.js +548 -0
- package/internal/kernel-child.js +139 -0
- package/internal/kernel-worker.js +904 -120
- package/internal/language-semantics.js +14 -0
- package/internal/legacy-binding-catalog.js +48 -0
- package/internal/legacy-cell-analysis.js +600 -0
- package/internal/legacy-cell-parser.js +50 -0
- package/internal/legacy-cell-rewriter.js +631 -0
- package/internal/legacy-repl-convenience.js +291 -0
- package/internal/managed-module-operations.js +170 -0
- package/internal/module-compilation.js +321 -0
- package/internal/module-runtime-platform.js +19 -0
- package/internal/module-transform-contract.js +12 -0
- package/internal/native-await-declarations.js +68 -0
- package/internal/native-root-compilation.js +54 -0
- package/internal/native-root-dynamic.js +67 -0
- package/internal/recovery-tips.js +27 -15
- package/internal/rejected-cell-editor.js +13 -8
- package/internal/repl-convenience.js +2 -289
- package/internal/repl-memory-projection.js +81 -12
- package/internal/repl-observation-interest.js +70 -0
- package/internal/repl-rewrite-contract.js +6 -0
- package/internal/repl-scope-normalizer.js +2091 -0
- package/internal/repl-value-observer.js +114 -0
- package/internal/rpc-contract.js +52 -0
- package/internal/runtime-bridge-owner.js +172 -28
- package/internal/runtime-config.js +48 -52
- package/internal/runtime-contexts.js +82 -10
- package/internal/runtime-messages.js +111 -0
- package/internal/session-cell-executor.js +401 -87
- package/internal/session-journal-recovery.js +684 -0
- package/internal/session-journal-schema.js +106 -0
- package/internal/session-journal.js +184 -654
- package/internal/session-log-view.js +42 -9
- package/internal/session-runtime.js +256 -95
- package/internal/session-state.js +247 -71
- package/internal/source-position-map.js +211 -71
- package/internal/stateful-module-compiler.js +154 -0
- package/internal/stateful-module-runtime.js +303 -0
- package/internal/stateful-root-compiler.js +598 -0
- package/internal/stateful-root-runtime.js +340 -0
- package/internal/typert-codec-compat.js +28 -0
- package/internal/typescript-transform.js +29 -0
- package/internal/user-binding-authoring.js +70 -0
- package/internal/user-binding-console-worker.js +184 -0
- package/internal/user-binding-console.js +148 -0
- package/internal/user-binding-draft-projection.js +335 -0
- package/internal/user-binding-model-context.js +31 -0
- package/internal/user-binding-runner.js +56 -0
- package/internal/user-bindings-owner.js +1065 -0
- package/internal/user-bindings-store.js +293 -0
- package/internal/user-bindings.js +353 -0
- package/internal/value-wire-schema.js +28 -0
- package/internal/value-wire.js +29 -20
- package/internal/worker-client.js +57 -67
- package/internal/worker-environment.js +45 -0
- package/internal/worker-output-relay.js +29 -0
- package/internal/worker-shutdown.js +14 -0
- package/package.json +75 -7
- package/src/client-activity.js +88 -53
- package/src/client-authoring-view.js +580 -0
- package/src/client-binding-review.js +230 -0
- package/src/client-bindings-data.js +29 -0
- package/src/client-catalog.js +127 -0
- package/src/client-code-editor.js +84 -0
- package/src/client-console.js +112 -0
- package/src/client-copy.js +370 -0
- package/src/client-feature-gates.js +62 -0
- package/src/client-host-compat.js +150 -0
- package/src/client-indicator-view.js +267 -0
- package/src/client-repl-view.js +292 -0
- package/src/client-rpc.js +20 -0
- package/src/client-settings-view.js +221 -0
- package/src/client-styles.js +106 -0
- package/src/client-tool-view.js +108 -0
- package/src/client-transport.js +159 -0
- package/src/client-workbench.js +521 -0
- package/src/client.js +233 -615
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
import { blankBinding, editableBinding, bindingPayload } from './client-bindings-data.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* User Bindings workbench. The controller owns every piece of edit state in one
|
|
5
|
+
* reducer: selection, the stored identity it was loaded from, the revision the
|
|
6
|
+
* next write is compared against, the editor document identity and the draft.
|
|
7
|
+
* Async responses carry the generation they started in, so a late response can
|
|
8
|
+
* never overwrite a newer selection, and the document identity only changes when
|
|
9
|
+
* the edited document itself changes - never because draft.id was retyped.
|
|
10
|
+
* Callers keep the controller in a stable owner so hiding the dialog does not
|
|
11
|
+
* discard an unsaved draft.
|
|
12
|
+
*/
|
|
13
|
+
export function createUserBindingsWorkbench(React, deps) {
|
|
14
|
+
const { TypeScriptEditor, BindingConsole, IconButton, ActionButton, CodeBlock, Modal, icons, catalogOwner } = deps
|
|
15
|
+
const h = React.createElement
|
|
16
|
+
|
|
17
|
+
const initialState = Object.freeze({
|
|
18
|
+
documentId: 0,
|
|
19
|
+
consoleVersion: 0,
|
|
20
|
+
catalog: null,
|
|
21
|
+
catalogStatus: 'loading',
|
|
22
|
+
catalogError: null,
|
|
23
|
+
draft: null,
|
|
24
|
+
declaration: '',
|
|
25
|
+
original: null,
|
|
26
|
+
revision: null,
|
|
27
|
+
editing: false,
|
|
28
|
+
creating: false,
|
|
29
|
+
query: '',
|
|
30
|
+
importPath: '',
|
|
31
|
+
sourceOpen: false,
|
|
32
|
+
metadataOpen: false,
|
|
33
|
+
message: null,
|
|
34
|
+
busy: false,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// Selection keeps the document identity when the same stored entry stays in the
|
|
38
|
+
// editor; a replaced source releases the console environment instead. Loading a
|
|
39
|
+
// stored entry or clearing the editor always ends the new-draft mode, so a later
|
|
40
|
+
// reload or cancel of that unchanged entry is not mistaken for a different document.
|
|
41
|
+
const selectDocument = (state, loaded, resetConsole) => {
|
|
42
|
+
const entry = loaded?.entry ?? null
|
|
43
|
+
const sameDocument = entry !== null && state.draft !== null && !state.creating
|
|
44
|
+
&& state.original?.id === entry.id
|
|
45
|
+
const replaced = resetConsole !== false && state.draft?.source !== entry?.source
|
|
46
|
+
return {
|
|
47
|
+
...state,
|
|
48
|
+
documentId: sameDocument ? state.documentId : state.documentId + 1,
|
|
49
|
+
consoleVersion: replaced ? state.consoleVersion + 1 : state.consoleVersion,
|
|
50
|
+
draft: entry === null ? null : editableBinding(entry),
|
|
51
|
+
original: entry,
|
|
52
|
+
declaration: entry?.declaration ?? '',
|
|
53
|
+
revision: loaded?.revision ?? null,
|
|
54
|
+
editing: false,
|
|
55
|
+
creating: false,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The reducer is a pure function of the actions it is handed; stale async
|
|
60
|
+
// responses are dropped before dispatch, never inside the reducer.
|
|
61
|
+
function reducer(state, action) {
|
|
62
|
+
switch (action.type) {
|
|
63
|
+
case 'begin':
|
|
64
|
+
return { ...state, busy: true, message: null }
|
|
65
|
+
case 'settle':
|
|
66
|
+
return { ...state, busy: false }
|
|
67
|
+
case 'failed':
|
|
68
|
+
return { ...state, message: action.message }
|
|
69
|
+
case 'catalog':
|
|
70
|
+
// A failed read reports its error but keeps the last good catalog, so the
|
|
71
|
+
// surface stays usable and the message area owns the failure.
|
|
72
|
+
return { ...state, catalog: action.snapshot.catalog ?? state.catalog,
|
|
73
|
+
catalogStatus: action.snapshot.status, catalogError: action.snapshot.error }
|
|
74
|
+
case 'patch':
|
|
75
|
+
return { ...state, ...action.patch }
|
|
76
|
+
case 'loaded':
|
|
77
|
+
return selectDocument(state, action.loaded, action.resetConsole)
|
|
78
|
+
case 'create':
|
|
79
|
+
return { ...state,
|
|
80
|
+
documentId: state.documentId + 1, consoleVersion: state.consoleVersion + 1,
|
|
81
|
+
draft: blankBinding(), original: null, revision: action.revision, editing: true, creating: true,
|
|
82
|
+
sourceOpen: true, metadataOpen: true, declaration: '', message: null, busy: false }
|
|
83
|
+
case 'edit':
|
|
84
|
+
return { ...state, draft: { ...state.draft, [action.key]: action.value }, editing: true,
|
|
85
|
+
message: null, declaration: action.key === 'modelContext' ? state.declaration : '' }
|
|
86
|
+
case 'validated':
|
|
87
|
+
return { ...state, draft: editableBinding(action.normalized),
|
|
88
|
+
declaration: action.normalized.declaration ?? '', message: { key: 'bindings.valid' } }
|
|
89
|
+
case 'saved':
|
|
90
|
+
return { ...state, catalog: action.next, catalogStatus: 'ready', catalogError: null,
|
|
91
|
+
documentId: state.draft?.id === action.normalized.id ? state.documentId : state.documentId + 1,
|
|
92
|
+
consoleVersion: state.original?.source !== action.normalized.source
|
|
93
|
+
? state.consoleVersion + 1 : state.consoleVersion,
|
|
94
|
+
draft: editableBinding(action.normalized), declaration: action.normalized.declaration ?? '',
|
|
95
|
+
message: { key: 'bindings.saved' }, original: action.normalized,
|
|
96
|
+
revision: action.next.revision, editing: false, creating: false, sourceOpen: false }
|
|
97
|
+
case 'mutated': {
|
|
98
|
+
const next = { ...state, catalog: action.next, catalogStatus: 'ready', catalogError: null,
|
|
99
|
+
// A successful CAS on this baseline proves metadata-only changes preserve its source.
|
|
100
|
+
revision: state.revision === state.catalog?.revision ? action.next.revision : state.revision }
|
|
101
|
+
// The response catalog, not the entry the request started from, is the
|
|
102
|
+
// confirmed stored state; only that field changes, so unsaved edits stay.
|
|
103
|
+
const stored = action.entry === undefined ? undefined
|
|
104
|
+
: action.next.entries.find(entry => entry.id === action.entry.id)
|
|
105
|
+
if (stored !== undefined && state.draft?.id === stored.id) {
|
|
106
|
+
next.draft = { ...state.draft, enabled: stored.enabled }
|
|
107
|
+
next.original = state.original === null ? null : { ...state.original, enabled: stored.enabled }
|
|
108
|
+
}
|
|
109
|
+
if (action.removed === true && state.draft?.id === action.entry.id) {
|
|
110
|
+
next.draft = null
|
|
111
|
+
next.original = null
|
|
112
|
+
next.declaration = ''
|
|
113
|
+
next.revision = null
|
|
114
|
+
next.editing = false
|
|
115
|
+
// The removed entry was the edited document; no new draft survives it.
|
|
116
|
+
next.creating = false
|
|
117
|
+
next.documentId = state.documentId + 1
|
|
118
|
+
next.consoleVersion = state.consoleVersion + 1
|
|
119
|
+
}
|
|
120
|
+
if (action.imported === true) next.importPath = ''
|
|
121
|
+
next.message = action.removed === true ? { key: 'bindings.removed' }
|
|
122
|
+
: action.imported === true ? { key: 'bindings.imported' } : state.message
|
|
123
|
+
return next
|
|
124
|
+
}
|
|
125
|
+
case 'reloadResult': {
|
|
126
|
+
if (action.loaded !== null && action.loaded.revision !== action.next.revision) {
|
|
127
|
+
return { ...state, message: { key: 'bindings.reloadConflict' } }
|
|
128
|
+
}
|
|
129
|
+
const base = { ...state, catalog: action.next, catalogStatus: 'ready', catalogError: null }
|
|
130
|
+
if (state.editing) {
|
|
131
|
+
// Keep the unsaved draft; only its stored identity and baseline are refreshed.
|
|
132
|
+
// An entry that disappeared externally keeps its identity, so the next save
|
|
133
|
+
// is still an update of that entry and never an implicit creation.
|
|
134
|
+
return { ...base, original: action.loaded?.entry ?? state.original,
|
|
135
|
+
revision: action.loaded?.revision ?? action.next.revision,
|
|
136
|
+
message: { key: 'bindings.reloadedDraft' } }
|
|
137
|
+
}
|
|
138
|
+
return { ...selectDocument(base, action.loaded, true), message: { key: 'bindings.reloaded' } }
|
|
139
|
+
}
|
|
140
|
+
case 'reset':
|
|
141
|
+
return { ...initialState, documentId: state.documentId + 1,
|
|
142
|
+
consoleVersion: state.consoleVersion + 1 }
|
|
143
|
+
default:
|
|
144
|
+
return state
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// `enabled` owns the management session: disabling it releases the draft and
|
|
149
|
+
// invalidates every in-flight response, so a closed or disabled surface is
|
|
150
|
+
// never completed by a late answer. `active` only suspends reads while a
|
|
151
|
+
// temporary Host takeover hides the surface.
|
|
152
|
+
function useWorkbenchController({ enabled, active = true, callUserBindings }) {
|
|
153
|
+
const [state, dispatch] = React.useReducer(reducer, initialState)
|
|
154
|
+
const generation = React.useRef(0)
|
|
155
|
+
// The generation that holds the single-flight lock, or null while free.
|
|
156
|
+
const running = React.useRef(null)
|
|
157
|
+
const autoLoaded = React.useRef(false)
|
|
158
|
+
const source = React.useMemo(() => catalogOwner.claim(), [catalogOwner])
|
|
159
|
+
const snapshot = React.useSyncExternalStore(source.subscribe, source.getSnapshot)
|
|
160
|
+
React.useEffect(() => () => {
|
|
161
|
+
// A response arriving after this owner is gone must not dispatch.
|
|
162
|
+
generation.current += 1
|
|
163
|
+
source.release()
|
|
164
|
+
}, [source])
|
|
165
|
+
React.useEffect(() => {
|
|
166
|
+
dispatch({ type: 'catalog', snapshot })
|
|
167
|
+
// A read that fails on the shared source is this surface's failure too.
|
|
168
|
+
if (snapshot.status === 'error' && snapshot.error !== null) {
|
|
169
|
+
dispatch({ type: 'failed', message: { key: 'bindings.failed', params: { error: snapshot.error } } })
|
|
170
|
+
}
|
|
171
|
+
}, [snapshot])
|
|
172
|
+
|
|
173
|
+
// One operation at a time. Every response carries the generation it started
|
|
174
|
+
// in, so a late answer can never overwrite a newer selection or a new draft.
|
|
175
|
+
// A lock held by a released generation does not block the surface that
|
|
176
|
+
// replaced it; the released request still settles as a dropped answer.
|
|
177
|
+
const perform = React.useCallback(async operation => {
|
|
178
|
+
if (running.current === generation.current) return
|
|
179
|
+
const current = ++generation.current
|
|
180
|
+
running.current = current
|
|
181
|
+
const alive = () => current === generation.current
|
|
182
|
+
dispatch({ type: 'begin' })
|
|
183
|
+
try {
|
|
184
|
+
await operation(alive)
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (alive()) dispatch({ type: 'failed', message: {
|
|
187
|
+
key: 'bindings.failed',
|
|
188
|
+
params: { error: error instanceof Error ? error.message : String(error) },
|
|
189
|
+
} })
|
|
190
|
+
} finally {
|
|
191
|
+
if (running.current === current) running.current = null
|
|
192
|
+
if (alive()) dispatch({ type: 'settle' })
|
|
193
|
+
}
|
|
194
|
+
}, [])
|
|
195
|
+
|
|
196
|
+
const load = React.useCallback(id => perform(async alive => {
|
|
197
|
+
const loaded = await callUserBindings('load', { id })
|
|
198
|
+
if (!alive()) return
|
|
199
|
+
dispatch({ type: 'loaded', loaded, resetConsole: true })
|
|
200
|
+
}), [perform, callUserBindings])
|
|
201
|
+
|
|
202
|
+
React.useEffect(() => {
|
|
203
|
+
if (!enabled) {
|
|
204
|
+
autoLoaded.current = false
|
|
205
|
+
generation.current += 1
|
|
206
|
+
source.reset()
|
|
207
|
+
dispatch({ type: 'reset' })
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
if (!active) return
|
|
211
|
+
// Re-entering the surface re-reads the catalog, but keeps the draft it left behind.
|
|
212
|
+
autoLoaded.current = false
|
|
213
|
+
void source.read()
|
|
214
|
+
}, [enabled, active, source])
|
|
215
|
+
|
|
216
|
+
React.useEffect(() => {
|
|
217
|
+
if (!enabled || !active || autoLoaded.current) return
|
|
218
|
+
if (state.draft !== null || state.original !== null) {
|
|
219
|
+
autoLoaded.current = true
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
const entries = snapshot.status === 'ready' ? snapshot.catalog?.entries : undefined
|
|
223
|
+
if (entries === undefined || entries.length === 0) return
|
|
224
|
+
autoLoaded.current = true
|
|
225
|
+
void load(entries[0].id)
|
|
226
|
+
}, [enabled, active, snapshot, state.draft, state.original, load])
|
|
227
|
+
|
|
228
|
+
const edit = (key, value) => dispatch({ type: 'edit', key, value })
|
|
229
|
+
const patch = value => dispatch({ type: 'patch', patch: value })
|
|
230
|
+
// A new draft is saved against the catalog revision it was created from, so
|
|
231
|
+
// creation needs a confirmed one. The last good catalog stays usable while a
|
|
232
|
+
// refresh is in flight or fails, and an empty catalog is a valid baseline
|
|
233
|
+
// (revision 0); a first read that has not answered, or failed, is not. The
|
|
234
|
+
// button and the transition consume this one predicate.
|
|
235
|
+
const catalogRevision = state.catalog !== null && Number.isInteger(state.catalog.revision)
|
|
236
|
+
? state.catalog.revision : null
|
|
237
|
+
const canCreate = catalogRevision !== null && !state.busy && !state.editing
|
|
238
|
+
const create = () => {
|
|
239
|
+
if (!canCreate) return
|
|
240
|
+
generation.current += 1
|
|
241
|
+
dispatch({ type: 'create', revision: catalogRevision })
|
|
242
|
+
}
|
|
243
|
+
const cancel = () => {
|
|
244
|
+
dispatch({ type: 'loaded', loaded: state.original === null ? null : { entry: state.original, revision: state.revision },
|
|
245
|
+
resetConsole: false })
|
|
246
|
+
patch({ sourceOpen: false, message: null })
|
|
247
|
+
}
|
|
248
|
+
const validate = () => perform(async alive => {
|
|
249
|
+
const normalized = await callUserBindings('validate', { entry: bindingPayload(state.draft) })
|
|
250
|
+
if (!alive()) return
|
|
251
|
+
dispatch({ type: 'validated', normalized })
|
|
252
|
+
})
|
|
253
|
+
const save = () => perform(async alive => {
|
|
254
|
+
const normalized = await callUserBindings('validate', { entry: bindingPayload(state.draft) })
|
|
255
|
+
if (!alive()) return
|
|
256
|
+
// Creating and editing are different host operations: a create may not
|
|
257
|
+
// silently take over an existing entry, and an edit may only rewrite the
|
|
258
|
+
// entry it was loaded from.
|
|
259
|
+
const next = await callUserBindings('save', {
|
|
260
|
+
intent: state.original === null ? 'create' : 'update',
|
|
261
|
+
originalId: state.original?.id ?? null,
|
|
262
|
+
entry: bindingPayload(editableBinding(normalized)),
|
|
263
|
+
expectedRevision: state.revision,
|
|
264
|
+
})
|
|
265
|
+
if (!alive()) return
|
|
266
|
+
source.accept(next)
|
|
267
|
+
dispatch({ type: 'saved', normalized, next })
|
|
268
|
+
})
|
|
269
|
+
const reload = () => perform(async alive => {
|
|
270
|
+
const next = await source.read({ reload: true })
|
|
271
|
+
if (next === undefined || !alive()) return
|
|
272
|
+
const selected = next.entries.find(entry => entry.id === state.original?.id)
|
|
273
|
+
const loaded = selected === undefined ? null : await callUserBindings('load', { id: selected.id })
|
|
274
|
+
if (!alive()) return
|
|
275
|
+
dispatch({ type: 'reloadResult', next, loaded })
|
|
276
|
+
})
|
|
277
|
+
const toggle = entry => perform(async alive => {
|
|
278
|
+
const next = await callUserBindings(entry.enabled ? 'disable' : 'enable', {
|
|
279
|
+
id: entry.id, expectedRevision: state.catalog.revision,
|
|
280
|
+
})
|
|
281
|
+
if (!alive()) return
|
|
282
|
+
source.accept(next)
|
|
283
|
+
dispatch({ type: 'mutated', next, entry })
|
|
284
|
+
})
|
|
285
|
+
const remove = entry => perform(async alive => {
|
|
286
|
+
const next = await callUserBindings('remove', {
|
|
287
|
+
id: entry.id, expectedRevision: state.catalog.revision,
|
|
288
|
+
})
|
|
289
|
+
if (!alive()) return
|
|
290
|
+
source.accept(next)
|
|
291
|
+
dispatch({ type: 'mutated', next, entry, removed: true })
|
|
292
|
+
})
|
|
293
|
+
const importSource = () => perform(async alive => {
|
|
294
|
+
const next = await callUserBindings('import', {
|
|
295
|
+
path: state.importPath, expectedRevision: state.catalog.revision,
|
|
296
|
+
})
|
|
297
|
+
if (!alive()) return
|
|
298
|
+
source.accept(next)
|
|
299
|
+
dispatch({ type: 'mutated', next, imported: true })
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
return { state, source, snapshot, callUserBindings, canCreate, edit, patch, create, cancel, load,
|
|
303
|
+
validate, save, reload, toggle, remove, importSource }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function UserBindingsWorkbench({ controller, enabled = true, t, heading = true, headingLabel }) {
|
|
307
|
+
const { state, canCreate } = controller
|
|
308
|
+
const { edit, patch, create, cancel, load, validate, save, reload, toggle, remove, importSource } = controller
|
|
309
|
+
if (!enabled) return null
|
|
310
|
+
const visibleEntries = state.catalog?.entries.filter(entry =>
|
|
311
|
+
entry.name.toLowerCase().includes(state.query.trim().toLowerCase())) ?? []
|
|
312
|
+
return h('section', { className: 'ptcPlusBindings', 'aria-label': t('bindings.title'), 'aria-busy': state.busy },
|
|
313
|
+
h('div', { className: 'ptcPlusBindingsHead' },
|
|
314
|
+
heading ? h('h3', { className: 'ptcPlusBindingsTitle' }, headingLabel ?? t('bindings.title')) : h('span'),
|
|
315
|
+
h('div', { className: 'ptcPlusBindingsActions' },
|
|
316
|
+
h(IconButton, {
|
|
317
|
+
icon: icons.refresh, label: t('bindings.reload'), disabled: state.busy,
|
|
318
|
+
onClick: reload,
|
|
319
|
+
}),
|
|
320
|
+
h(ActionButton, {
|
|
321
|
+
type: 'button', className: 'ptcPlusButton', 'data-kind': 'primary', disabled: !canCreate,
|
|
322
|
+
onClick: create,
|
|
323
|
+
}, typeof icons.plus === 'function' ? h(icons.plus, { size: 16 }) : null, t('bindings.new')))),
|
|
324
|
+
state.catalog === null
|
|
325
|
+
? h('p', {
|
|
326
|
+
// A failed read publishes its error on the shared source; without it
|
|
327
|
+
// the surface would keep announcing that it is still loading.
|
|
328
|
+
className: `ptcPlusMessage${state.message === null && state.catalogError === null ? '' : ' ptcPlusDanger'}`,
|
|
329
|
+
role: state.message === null && state.catalogError === null ? undefined : 'status',
|
|
330
|
+
}, state.message !== null ? t(state.message.key, state.message.params)
|
|
331
|
+
: state.catalogError !== null ? t('bindings.failed', { error: state.catalogError })
|
|
332
|
+
: t('bindings.loading'))
|
|
333
|
+
: h('div', { className: 'ptcPlusBindingsGrid' },
|
|
334
|
+
h('div', { className: 'ptcPlusBindingPane' },
|
|
335
|
+
h('label', { className: 'ptcPlusSearch' },
|
|
336
|
+
typeof icons.search === 'function' ? h(icons.search, { size: 16 }) : null,
|
|
337
|
+
h('input', { value: state.query, onChange: event => patch({ query: event.target.value }),
|
|
338
|
+
placeholder: t('bindings.search'), 'aria-label': t('bindings.search') })),
|
|
339
|
+
state.catalog.error === undefined
|
|
340
|
+
? null
|
|
341
|
+
: h('p', { className: 'ptcPlusMessage ptcPlusDanger' }, state.catalog.error),
|
|
342
|
+
state.catalog.entries.length === 0
|
|
343
|
+
? h('p', { className: 'ptcPlusMessage' }, t('bindings.empty'))
|
|
344
|
+
: h('ul', { className: 'ptcPlusBindingList' }, visibleEntries.map(entry => (
|
|
345
|
+
h('li', {
|
|
346
|
+
key: entry.id, className: 'ptcPlusBindingItem',
|
|
347
|
+
'data-selected': state.draft?.id === entry.id ? true : undefined,
|
|
348
|
+
},
|
|
349
|
+
h('button', {
|
|
350
|
+
type: 'button', className: 'ptcPlusBindingSelect', disabled: state.busy || state.editing,
|
|
351
|
+
onClick: () => load(entry.id),
|
|
352
|
+
},
|
|
353
|
+
h('span', { className: 'ptcPlusBindingName', title: entry.name }, entry.name),
|
|
354
|
+
h('span', { className: 'ptcPlusBindingMeta', title: entry.symbols.join(', ') }, entry.scope)),
|
|
355
|
+
h('button', {
|
|
356
|
+
type: 'button', className: 'ptcPlusBindingSwitch', role: 'switch',
|
|
357
|
+
disabled: state.busy || state.editing, 'aria-checked': entry.enabled,
|
|
358
|
+
'aria-label': `${entry.name}: ${t('bindings.enabled')}`,
|
|
359
|
+
title: t(entry.enabled ? 'bindings.disableAction' : 'bindings.enableAction'),
|
|
360
|
+
onClick: () => toggle(entry),
|
|
361
|
+
}, h('span', { 'aria-hidden': true })))
|
|
362
|
+
))),
|
|
363
|
+
state.catalog.entries.length > 0 && visibleEntries.length === 0
|
|
364
|
+
? h('p', { className: 'ptcPlusMessage' }, t('console.noMatches')) : null,
|
|
365
|
+
h('div', { className: 'ptcPlusBindingRun' },
|
|
366
|
+
h('input', {
|
|
367
|
+
className: 'ptcPlusInput', value: state.importPath, disabled: state.busy,
|
|
368
|
+
placeholder: t('bindings.importPath'), 'aria-label': t('bindings.importPath'),
|
|
369
|
+
onChange: event => patch({ importPath: event.target.value }),
|
|
370
|
+
}),
|
|
371
|
+
h(ActionButton, {
|
|
372
|
+
type: 'button', className: 'ptcPlusButton',
|
|
373
|
+
disabled: state.busy || state.editing || state.importPath.trim() === '', onClick: importSource,
|
|
374
|
+
}, t('bindings.import')))),
|
|
375
|
+
state.draft === null ? null : h('div', { className: 'ptcPlusBindingEditor' },
|
|
376
|
+
h('div', { className: 'ptcPlusEditorHead' },
|
|
377
|
+
h('strong', { className: 'ptcPlusEditorFile' }, state.draft.name || t('bindings.new')),
|
|
378
|
+
h('div', { className: 'ptcPlusBindingLifecycle' },
|
|
379
|
+
state.editing ? h(React.Fragment, null,
|
|
380
|
+
h(ActionButton, { disabled: state.busy, onClick: validate },
|
|
381
|
+
h(icons.check, { size: 14 }), t('bindings.validate')),
|
|
382
|
+
h(ActionButton, { 'data-kind': 'primary', disabled: state.busy, onClick: save }, t('bindings.save')),
|
|
383
|
+
h(ActionButton, { disabled: state.busy, onClick: cancel }, t('bindings.cancel'))) : null,
|
|
384
|
+
state.catalog.entries.some(entry => entry.id === state.draft.id)
|
|
385
|
+
? h(IconButton, {
|
|
386
|
+
icon: icons.trash, label: t('bindings.remove'), 'data-kind': 'danger',
|
|
387
|
+
disabled: state.busy || state.editing, onClick: () => remove(state.draft),
|
|
388
|
+
})
|
|
389
|
+
: null)),
|
|
390
|
+
h('details', { className: 'ptcPlusBindingSection ptcPlusBindingSourcePreview', open: true },
|
|
391
|
+
h('summary', { className: 'ptcPlusBindingFieldLabel' }, t('bindings.declaration')),
|
|
392
|
+
state.declaration === '' ? h('p', { className: 'ptcPlusMessage' }, t('bindings.unvalidated'))
|
|
393
|
+
: typeof CodeBlock === 'function'
|
|
394
|
+
? h(CodeBlock, { code: state.declaration, lang: 'typescript', className: 'ptcPlusCodeBlock',
|
|
395
|
+
copyLabel: t('tool.copy'), copiedLabel: t('tool.copied') })
|
|
396
|
+
: h('pre', { className: 'ptcPlusDeclaration' }, state.declaration)),
|
|
397
|
+
h('section', { className: 'ptcPlusBindingSection ptcPlusModelPrompt' },
|
|
398
|
+
h('h4', { className: 'ptcPlusBindingSectionTitle' }, t('bindings.modelContext')),
|
|
399
|
+
h('div', { className: 'ptcPlusBindingFields' },
|
|
400
|
+
h('label', { className: 'ptcPlusBindingField ptcPlusPromptToggle', 'data-wide': true },
|
|
401
|
+
h('span', { className: 'ptcPlusBindingFieldLabel' }, t('bindings.includeDeclaration')),
|
|
402
|
+
h('input', { className: 'ptcPlusCheck', type: 'checkbox', checked: state.draft.modelContext.includeDeclaration, disabled: state.busy,
|
|
403
|
+
onChange: event => edit('modelContext', { ...state.draft.modelContext, includeDeclaration: event.target.checked }) })),
|
|
404
|
+
h('label', { className: 'ptcPlusBindingField', 'data-wide': true },
|
|
405
|
+
h('span', { className: 'ptcPlusBindingFieldLabel' }, t('bindings.instructions')),
|
|
406
|
+
h('textarea', { className: 'ptcPlusInput', rows: 3, maxLength: 4096,
|
|
407
|
+
value: state.draft.modelContext.instructions, disabled: state.busy,
|
|
408
|
+
onChange: event => edit('modelContext', { ...state.draft.modelContext, instructions: event.target.value }) })))),
|
|
409
|
+
h('div', { className: 'ptcPlusSourceSection' },
|
|
410
|
+
h('button', { type: 'button', className: 'ptcPlusSourceToggle',
|
|
411
|
+
'aria-expanded': state.sourceOpen, onClick: () => patch({ sourceOpen: !state.sourceOpen }) },
|
|
412
|
+
h(icons.chevron, { size: 14, style: { transform: state.sourceOpen ? undefined : 'rotate(-90deg)' } }),
|
|
413
|
+
t('bindings.sourcePreview'),
|
|
414
|
+
h('span', { className: 'ptcPlusSourceFilename' }, state.draft.name ? `${state.draft.name}.ts` : '')),
|
|
415
|
+
h('div', { className: 'ptcPlusSourceActions' }, state.editing ? null
|
|
416
|
+
: h(ActionButton, { disabled: state.busy, onClick: () => patch({ editing: true, sourceOpen: true }) },
|
|
417
|
+
typeof icons.edit === 'function' ? h(icons.edit, { size: 14 }) : null, t('bindings.edit'))),
|
|
418
|
+
h('div', { className: 'ptcPlusSourceBody', hidden: !state.sourceOpen },
|
|
419
|
+
state.editing
|
|
420
|
+
? h(TypeScriptEditor, { documentId: state.documentId, value: state.draft.source, disabled: state.busy,
|
|
421
|
+
label: t('bindings.source'), onChange: value => edit('source', value) })
|
|
422
|
+
: state.sourceOpen && typeof CodeBlock === 'function'
|
|
423
|
+
? h(CodeBlock, { code: state.draft.source, lang: 'typescript', className: 'ptcPlusSourceCode',
|
|
424
|
+
copyLabel: t('tool.copy'), copiedLabel: t('tool.copied') })
|
|
425
|
+
: state.sourceOpen ? h('pre', { className: 'ptcPlusSourceCode' }, state.draft.source) : null)),
|
|
426
|
+
h('div', { className: 'ptcPlusWorkbenchFeedback', role: 'status' },
|
|
427
|
+
state.busy ? t('bindings.working')
|
|
428
|
+
: state.message === null ? '' : t(state.message.key, state.message.params)),
|
|
429
|
+
h(BindingConsole, { key: state.documentId, entryId: `workbench:${state.documentId}`,
|
|
430
|
+
source: state.draft.source, resetVersion: state.consoleVersion,
|
|
431
|
+
callUserBindings: controller.callUserBindings, t }),
|
|
432
|
+
h('details', { className: 'ptcPlusBindingSection ptcPlusEntrySettings', open: state.metadataOpen,
|
|
433
|
+
onToggle: event => patch({ metadataOpen: event.currentTarget.open }) },
|
|
434
|
+
h('summary', { className: 'ptcPlusBindingSectionTitle' }, t('bindings.entry')),
|
|
435
|
+
h('div', { className: 'ptcPlusBindingFields' },
|
|
436
|
+
h('label', { className: 'ptcPlusBindingField' },
|
|
437
|
+
h('span', { className: 'ptcPlusBindingFieldLabel' }, t('bindings.id')),
|
|
438
|
+
h('input', {
|
|
439
|
+
className: 'ptcPlusInput', value: state.draft.id,
|
|
440
|
+
// A stored entry is addressed by this id; only a new draft may choose it.
|
|
441
|
+
disabled: state.busy || !state.editing || state.original !== null,
|
|
442
|
+
onChange: event => edit('id', event.target.value),
|
|
443
|
+
})),
|
|
444
|
+
h('label', { className: 'ptcPlusBindingField' },
|
|
445
|
+
h('span', { className: 'ptcPlusBindingFieldLabel' }, t('bindings.name')),
|
|
446
|
+
h('input', {
|
|
447
|
+
className: 'ptcPlusInput', value: state.draft.name, disabled: state.busy || !state.editing,
|
|
448
|
+
onChange: event => edit('name', event.target.value),
|
|
449
|
+
})),
|
|
450
|
+
h('label', { className: 'ptcPlusBindingField' },
|
|
451
|
+
h('span', { className: 'ptcPlusBindingFieldLabel' }, t('bindings.scope')),
|
|
452
|
+
h('select', {
|
|
453
|
+
className: 'ptcPlusSelect', value: state.draft.scope, disabled: state.busy || !state.editing,
|
|
454
|
+
onChange: event => edit('scope', event.target.value),
|
|
455
|
+
}, h('option', { value: 'namespace' }, 'namespace'), h('option', { value: 'top-level' }, 'top-level'))),
|
|
456
|
+
h('label', { className: 'ptcPlusBindingField' },
|
|
457
|
+
h('span', { className: 'ptcPlusBindingFieldLabel' }, t('bindings.symbols')),
|
|
458
|
+
h('input', {
|
|
459
|
+
className: 'ptcPlusInput', value: state.draft.symbolsText, disabled: state.busy || !state.editing,
|
|
460
|
+
placeholder: t('bindings.symbolsPlaceholder'),
|
|
461
|
+
onChange: event => edit('symbolsText', event.target.value),
|
|
462
|
+
})),
|
|
463
|
+
h('label', { className: 'ptcPlusBindingField', 'data-wide': true },
|
|
464
|
+
h('span', { className: 'ptcPlusBindingFieldLabel' }, t('bindings.purpose')),
|
|
465
|
+
h('input', {
|
|
466
|
+
className: 'ptcPlusInput', value: state.draft.purpose, disabled: state.busy || !state.editing,
|
|
467
|
+
onChange: event => edit('purpose', event.target.value),
|
|
468
|
+
}))))),
|
|
469
|
+
state.message === null || state.draft !== null
|
|
470
|
+
? null
|
|
471
|
+
: h('p', { className: 'ptcPlusMessage', role: 'status' }, t(state.message.key, state.message.params))))
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function canReturnFocus(element) {
|
|
475
|
+
return element instanceof HTMLElement && element.isConnected
|
|
476
|
+
&& element !== document.body && (element.tabIndex >= 0 || element.isContentEditable)
|
|
477
|
+
&& !element.matches(':disabled, [aria-disabled="true"]')
|
|
478
|
+
&& !element.closest('[hidden], [inert], [aria-hidden="true"]')
|
|
479
|
+
&& element.getClientRects().length > 0 && getComputedStyle(element).visibility === 'visible'
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function BindingsDialog({ controller, t, onClose, returnFocusRef, getReturnFocus }) {
|
|
483
|
+
const content = React.useRef(null)
|
|
484
|
+
const localReturnFocus = React.useRef(null)
|
|
485
|
+
const returnFocus = returnFocusRef ?? localReturnFocus
|
|
486
|
+
React.useEffect(() => {
|
|
487
|
+
const previous = document.activeElement
|
|
488
|
+
// A resumed mount may have no focused control; keep the last valid entry.
|
|
489
|
+
if (!content.current.contains(previous) && canReturnFocus(previous)) returnFocus.current = previous
|
|
490
|
+
content.current.querySelector('button')?.focus()
|
|
491
|
+
}, [returnFocus])
|
|
492
|
+
const close = () => {
|
|
493
|
+
const target = canReturnFocus(returnFocus.current) ? returnFocus.current : getReturnFocus?.()
|
|
494
|
+
returnFocus.current = null
|
|
495
|
+
onClose()
|
|
496
|
+
// Only explicit closure restores focus. Temporary hiding leaves Host focus alone.
|
|
497
|
+
if (canReturnFocus(target)) target.focus({ preventScroll: true })
|
|
498
|
+
}
|
|
499
|
+
const trapFocus = event => {
|
|
500
|
+
if (event.key !== 'Tab') return
|
|
501
|
+
const controls = [...content.current.querySelectorAll('button:not(:disabled),input:not(:disabled),textarea:not(:disabled),select:not(:disabled),[contenteditable=true],summary')]
|
|
502
|
+
.filter(element => element.getClientRects().length > 0)
|
|
503
|
+
const first = controls[0]
|
|
504
|
+
const last = controls.at(-1)
|
|
505
|
+
if ((event.shiftKey && document.activeElement === first) || (!event.shiftKey && document.activeElement === last)) {
|
|
506
|
+
event.preventDefault()
|
|
507
|
+
;(event.shiftKey ? last : first)?.focus()
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return h(Modal, { open: true, onClose: close, title: t('bindings.title'), headless: true, className: 'ptcPlusBindingsModal' },
|
|
511
|
+
h('div', { className: 'ptcPlusBindingsDialog', ref: content, onKeyDown: trapFocus },
|
|
512
|
+
h('div', { className: 'ptcPlusBindingsDialogHead' },
|
|
513
|
+
h('h2', null, t('bindings.title')),
|
|
514
|
+
h('button', { type: 'button', className: 'ptcPlusDialogClose', onClick: close,
|
|
515
|
+
'aria-label': t('bindings.close'), title: t('bindings.close') }, h(icons.close, { size: 16 }))),
|
|
516
|
+
h('div', { className: 'ptcPlusBindingsSurface' },
|
|
517
|
+
h(UserBindingsWorkbench, { controller, t, heading: false }))))
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return { useWorkbenchController, UserBindingsWorkbench, BindingsDialog }
|
|
521
|
+
}
|