@jxsuite/studio 3.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/dist/chunks/Overlay.dev-723bv0df.js +23 -0
- package/dist/chunks/{cf-settings-zj2cj1ar.js → cf-settings-45fgyx8y.js} +6 -5
- package/dist/chunks/{components-z6cnkyy1.js → components-zmx10t62.js} +6 -5
- package/dist/chunks/content-source-t88vb2dr.js +49 -0
- package/dist/chunks/{convert-to-repeater-6vvx96n2.js → convert-to-repeater-dhw1nrz9.js} +27 -19
- package/dist/chunks/entry-commands-6z6bhxg0.js +46 -0
- package/dist/chunks/{extension-sections-kwzvpemr.js → extension-sections-3ydgjbdy.js} +32 -24
- package/dist/chunks/file-ops-rskmhxyq.js +48 -0
- package/dist/chunks/files-0cratsr0.js +92 -0
- package/dist/chunks/{format-host-8fr3545e.js → format-host-tgcmfs4p.js} +3 -3
- package/dist/chunks/fs-events-hrrg328g.js +21 -0
- package/dist/chunks/{git-panel-g01d93vc.js → git-panel-0yywyk76.js} +21 -16
- package/dist/chunks/{jx-validate-jj9rnt4n.js → jx-validate-8q2g05x1.js} +6 -4
- package/dist/chunks/layers-panel-z3tqe5cd.js +67 -0
- package/dist/chunks/layers-q75tj2mf.js +45 -0
- package/dist/chunks/lit-html-j055mkh8.js +22 -0
- package/dist/chunks/monaco-buffer-dqw0xxpx.js +25 -0
- package/dist/chunks/{monaco-setup-a5wkmbgh.js → monaco-setup-rws7k4br.js} +4 -4
- package/dist/chunks/notify-yjm79n4s.js +30 -0
- package/dist/chunks/parse-24d0gay3.js +19 -0
- package/dist/chunks/platform-ba1j1gbh.js +16 -0
- package/dist/chunks/publish-panel-54jvhskq.js +28 -0
- package/dist/chunks/quick-search-cgfmjwq5.js +65 -0
- package/dist/chunks/references-3qx8fkyp.js +29 -0
- package/dist/chunks/right-panel-cqp8bc4v.js +46 -0
- package/dist/chunks/sp-overlay.dev-yztmtrs1.js +18 -0
- package/dist/chunks/sp-popover.dev-n2etmqdp.js +9 -0
- package/dist/chunks/sp-progress-circle.dev-hryhj0es.js +15 -0
- package/dist/chunks/sp-tooltip.dev-xcvpnqr0.js +15 -0
- package/dist/chunks/{sp-tray.dev-64v4pxw8.js → sp-tray.dev-tkbscm06.js} +5 -5
- package/dist/chunks/{studio-8ezdegcx.js → studio-1hbqnt19.js} +36 -32
- package/dist/chunks/{studio-r5xbtctf.js → studio-2zkz8evn.js} +446 -2451
- package/dist/chunks/{studio-pkp61mh8.js → studio-4pqqxbrt.js} +23 -15
- package/dist/chunks/{studio-kvfx9z2c.js → studio-4rzyrtjz.js} +70 -31
- package/dist/chunks/{studio-3dkcd4c5.js → studio-5q69gfag.js} +60 -56
- package/dist/chunks/{studio-rh1cnamv.js → studio-5rywrkxz.js} +563 -133
- package/dist/chunks/{studio-6kv3apa9.js → studio-68n67094.js} +25 -9
- package/dist/chunks/{studio-0rqqhrdy.js → studio-7nte2hj4.js} +2 -2
- package/dist/chunks/{studio-xntfdy1x.js → studio-8swrjbny.js} +31 -18
- package/dist/chunks/studio-92ab2jqe.js +282 -0
- package/dist/chunks/{studio-sas7mt3k.js → studio-9ab8gqsm.js} +2 -2
- package/dist/chunks/{studio-ry1vz5sv.js → studio-9c4k4vhv.js} +15 -13
- package/dist/chunks/studio-9n4mrmnb.js +184 -0
- package/dist/chunks/{studio-tr20m8g8.js → studio-9r6j5ev1.js} +37 -2
- package/dist/chunks/{studio-2x9zbxmx.js → studio-ahjfdy6e.js} +9 -7
- package/dist/chunks/{studio-s519zc99.js → studio-bgxt93ga.js} +3 -3
- package/dist/chunks/{studio-dc8s8heb.js → studio-cb12yz9w.js} +8528 -8780
- package/dist/chunks/studio-cft9r4r7.js +119 -0
- package/dist/chunks/{studio-hcxzyk3x.js → studio-eenfkt4e.js} +6 -6
- package/dist/chunks/studio-eesa1myv.js +498 -0
- package/dist/chunks/{studio-ay8wcr2b.js → studio-eqq1w638.js} +7 -9
- package/dist/chunks/{studio-7p35254k.js → studio-ex8zyp98.js} +30 -26
- package/dist/chunks/studio-gv8vvshc.js +118 -0
- package/dist/chunks/studio-hbtp0eh1.js +1315 -0
- package/dist/chunks/{studio-a0yp7syn.js → studio-htsf0w6m.js} +29 -114
- package/dist/chunks/{studio-j595e748.js → studio-kjjsjak4.js} +139 -63
- package/dist/chunks/{studio-5x5774ak.js → studio-kryvsw6j.js} +3 -3
- package/dist/chunks/{studio-kfpek536.js → studio-kyh9avvv.js} +2 -2
- package/dist/chunks/{studio-g5gdqnyd.js → studio-kyk2v92a.js} +14 -14
- package/dist/chunks/{studio-twsafr1e.js → studio-m3hwn7y1.js} +3 -3
- package/dist/chunks/{studio-tzx3xmvx.js → studio-mmv72a27.js} +8 -8
- package/dist/chunks/{studio-vhk5fmfv.js → studio-n8b9wqwy.js} +5 -5
- package/dist/chunks/{studio-cjm7fdjf.js → studio-p4ngc046.js} +8 -104
- package/dist/chunks/{studio-p5dja96s.js → studio-p8j86wag.js} +2 -2
- package/dist/chunks/{studio-6z5eyjwn.js → studio-pdfjntbk.js} +66 -129
- package/dist/chunks/studio-q43a5y9h.js +394 -0
- package/dist/chunks/studio-q7v10cg2.js +106 -0
- package/dist/chunks/{studio-2w11grav.js → studio-qq6jym04.js} +3 -3
- package/dist/chunks/studio-r1r7afxx.js +1066 -0
- package/dist/chunks/{studio-3fk1ac9z.js → studio-rdx8jcxy.js} +4 -4
- package/dist/chunks/{studio-4mqb9a0j.js → studio-rn9ta2mz.js} +4 -4
- package/dist/chunks/studio-sf7zjgv1.js +91 -0
- package/dist/chunks/studio-t7qb9db8.js +665 -0
- package/dist/chunks/studio-tmc5emrw.js +8 -0
- package/dist/chunks/{studio-wdt9y92a.js → studio-txwkm6p9.js} +2 -2
- package/dist/chunks/{studio-k4jvjcfx.js → studio-v38hmvx1.js} +7 -7
- package/dist/chunks/{studio-7ejemhej.js → studio-vadzgkrz.js} +6 -6
- package/dist/chunks/studio-wcy8trfx.js +263 -0
- package/dist/chunks/{studio-5efrtn1m.js → studio-wx5609xd.js} +21 -17
- package/dist/chunks/{studio-qpfq7xhm.js → studio-xrapxwwc.js} +22 -285
- package/dist/chunks/{studio-hj2se583.js → studio-yxrkyv8a.js} +41 -31
- package/dist/chunks/{studio-xm3dbxvn.js → studio-zczps70q.js} +2 -2
- package/dist/chunks/workspace-xpres40v.js +89 -0
- package/dist/iframe-entry.js +108 -24
- package/dist/studio.js +2362 -1695
- package/package.json +16 -15
- package/src/browse/library-pane.ts +57 -3
- package/src/canvas/canvas-render.ts +23 -1
- package/src/canvas/iframe-patch.ts +47 -21
- package/src/canvas/iframe-render.ts +22 -6
- package/src/commands/app-commands.ts +4 -0
- package/src/commands/command-args.ts +29 -0
- package/src/commands/context.ts +15 -1
- package/src/commands/defaults.ts +29 -1
- package/src/commands/levels.ts +16 -0
- package/src/commands/live-context.ts +3 -0
- package/src/content/collection-match.ts +267 -0
- package/src/content/entry-commands.ts +38 -29
- package/src/content/entry-fields.ts +6 -0
- package/src/editor/shortcuts.ts +5 -0
- package/src/files/file-ops.ts +10 -42
- package/src/files/files.ts +485 -59
- package/src/files/fs-events.ts +9 -0
- package/src/files/gitignore.ts +432 -0
- package/src/files/serialize-document.ts +60 -0
- package/src/format/convert-file.ts +511 -0
- package/src/format/format-choices.ts +199 -0
- package/src/grid/grid-view.ts +16 -2
- package/src/media/media-open.ts +58 -0
- package/src/media/media-pane.ts +310 -0
- package/src/new-project/import-tab.ts +332 -160
- package/src/new-project/new-project-modal.ts +27 -34
- package/src/packages/ensure-deps.ts +14 -7
- package/src/packages/jxsuite-update.ts +11 -11
- package/src/page-params.ts +10 -46
- package/src/panels/activity-bar.ts +45 -16
- package/src/panels/ai-chat/attached-context.ts +6 -1
- package/src/panels/ai-chat/chat-view.ts +255 -13
- package/src/panels/ai-chat/composer.ts +21 -97
- package/src/panels/ai-panel.ts +116 -2
- package/src/panels/navigator-panels.ts +7 -6
- package/src/panels/settings-menu.ts +799 -0
- package/src/panels/toolbar.ts +135 -15
- package/src/platforms/cloud.ts +20 -0
- package/src/platforms/devserver.ts +67 -33
- package/src/preview/preview-overlay.ts +226 -0
- package/src/services/ai-ask.ts +247 -0
- package/src/services/ai-import-tools.ts +494 -0
- package/src/services/ai-project-tools.ts +20 -26
- package/src/services/ai-system-prompt.ts +88 -2
- package/src/services/ai-turn-signal.ts +70 -0
- package/src/services/connection.ts +74 -0
- package/src/services/context-manager.ts +89 -0
- package/src/services/document-assistant.ts +45 -4
- package/src/services/import-client.ts +33 -6
- package/src/services/import-run.ts +158 -0
- package/src/services/import-seed.ts +120 -0
- package/src/services/jx-validate.ts +58 -6
- package/src/services/project-adoption.ts +71 -0
- package/src/services/references.ts +0 -0
- package/src/services/settings/definitions.ts +17 -0
- package/src/services/tool-executor.ts +34 -2
- package/src/settings/contexts-section.ts +45 -3
- package/src/settings/dependencies-editor.ts +43 -23
- package/src/settings/general-settings.ts +30 -12
- package/src/settings/preferences-dialog.ts +18 -41
- package/src/settings/preferences-sections.ts +57 -0
- package/src/settings/settings-document.ts +65 -9
- package/src/studio.ts +33 -6
- package/src/tabs/transact.ts +42 -19
- package/src/types.ts +103 -5
- package/src/ui/ai-model-picker.ts +162 -0
- package/src/ui/choice-field.ts +72 -0
- package/src/ui/layers.ts +83 -5
- package/src/utils/studio-utils.ts +22 -52
- package/styles/forced-colors.css +7 -0
- package/styles/overlays.css +256 -0
- package/styles/panels.css +13 -1
- package/styles/shell.css +111 -0
- package/dist/chunks/Overlay.dev-d2ajy6sj.js +0 -23
- package/dist/chunks/content-source-7fy9qrm0.js +0 -40
- package/dist/chunks/files-xcybanxe.js +0 -70
- package/dist/chunks/layers-panel-27essgkc.js +0 -60
- package/dist/chunks/publish-panel-09sy2bab.js +0 -22
- package/dist/chunks/quick-search-sefb61v8.js +0 -55
- package/dist/chunks/right-panel-9r32jd5j.js +0 -39
- package/dist/chunks/sp-overlay.dev-s18hymn9.js +0 -18
- package/dist/chunks/sp-popover.dev-dj9x7e7a.js +0 -9
- package/dist/chunks/sp-progress-circle.dev-0zxcyz3r.js +0 -15
- package/dist/chunks/sp-tooltip.dev-5c6d1kjz.js +0 -15
- package/dist/chunks/studio-c31yb4t6.js +0 -55
- package/src/canvas/site-style-css.ts +0 -117
package/src/types.ts
CHANGED
|
@@ -32,10 +32,12 @@ import type {
|
|
|
32
32
|
GitLogEntry,
|
|
33
33
|
GitStatusResult,
|
|
34
34
|
ImportProgressEvent,
|
|
35
|
+
ImportReadyEvent,
|
|
35
36
|
ImportSiteOptions,
|
|
36
|
-
|
|
37
|
+
ImportSiteSummary,
|
|
37
38
|
PackageInfo,
|
|
38
39
|
PackageOpResult,
|
|
40
|
+
PackageVersionInfo,
|
|
39
41
|
ProjectListEntry,
|
|
40
42
|
ProjectSchemasResponse,
|
|
41
43
|
RecentProjectEntry,
|
|
@@ -79,12 +81,15 @@ export type {
|
|
|
79
81
|
GitFileStatus,
|
|
80
82
|
GitLogEntry,
|
|
81
83
|
GitStatusResult,
|
|
84
|
+
ImportBreakpointPolicy,
|
|
82
85
|
ImportProgressEvent,
|
|
86
|
+
ImportReadyEvent,
|
|
83
87
|
ImportSiteOptions,
|
|
88
|
+
ImportSiteSummary,
|
|
84
89
|
JsonValue,
|
|
85
|
-
OutdatedInfo,
|
|
86
90
|
PackageInfo,
|
|
87
91
|
PackageOpResult,
|
|
92
|
+
PackageVersionInfo,
|
|
88
93
|
ProjectListEntry,
|
|
89
94
|
ProjectSchemasResponse,
|
|
90
95
|
PullRequestInfo,
|
|
@@ -149,6 +154,20 @@ export interface SiteBuildResult {
|
|
|
149
154
|
routes: number;
|
|
150
155
|
files: number;
|
|
151
156
|
errors: string[];
|
|
157
|
+
/**
|
|
158
|
+
* How the backend produced what {@link url} serves.
|
|
159
|
+
*
|
|
160
|
+
* `"built"` is compiler output — what the published site will literally be. `"live"` is the
|
|
161
|
+
* working tree rendered as a site: the same pages at the same routes, assembled by the runtime in
|
|
162
|
+
* the reader's browser rather than prerendered, because the backend cannot run a build at all.
|
|
163
|
+
*
|
|
164
|
+
* The distinction is reported rather than hidden because the two differ in ways a reader would
|
|
165
|
+
* otherwise be misled by — a live preview has no prerendered HTML, no optimized images, no
|
|
166
|
+
* islands and no server-timing results — and because a live preview has something a build does
|
|
167
|
+
* not: it shows the tree as it stands, unsaved edits included. Absent means `"built"`, so a
|
|
168
|
+
* backend that predates this keeps its meaning.
|
|
169
|
+
*/
|
|
170
|
+
mode?: "built" | "live";
|
|
152
171
|
/**
|
|
153
172
|
* Origin the built site is browsable at, e.g. `http://127.0.0.1:41234`.
|
|
154
173
|
*
|
|
@@ -160,6 +179,24 @@ export interface SiteBuildResult {
|
|
|
160
179
|
url?: string;
|
|
161
180
|
}
|
|
162
181
|
|
|
182
|
+
/**
|
|
183
|
+
* What a live site preview reports back.
|
|
184
|
+
*
|
|
185
|
+
* Everything {@link SiteBuildResult} carries, plus the one answer only a live preview can give:
|
|
186
|
+
* whether the reader already had a tab on this project.
|
|
187
|
+
*/
|
|
188
|
+
export interface SitePreviewResult extends SiteBuildResult {
|
|
189
|
+
/**
|
|
190
|
+
* A tab already holding this project's preview took the route, so open nothing.
|
|
191
|
+
*
|
|
192
|
+
* The caller MUST honour it. One tab per project is the contract, and a caller that opens a
|
|
193
|
+
* second one anyway gives the author two tabs on the same project — which is precisely what
|
|
194
|
+
* retargeting the first one exists to prevent. `false` means the backend has no live client, or
|
|
195
|
+
* had one that did not answer, and the caller should open a tab itself.
|
|
196
|
+
*/
|
|
197
|
+
reused?: boolean;
|
|
198
|
+
}
|
|
199
|
+
|
|
163
200
|
export interface StudioPlatform {
|
|
164
201
|
id: string;
|
|
165
202
|
projectRoot: string;
|
|
@@ -266,6 +303,15 @@ export interface StudioPlatform {
|
|
|
266
303
|
*/
|
|
267
304
|
uploadFile: (path: string, data: string | File | Blob | ArrayBuffer) => Promise<UploadResult>;
|
|
268
305
|
deleteFile: (path: string) => Promise<void>;
|
|
306
|
+
/**
|
|
307
|
+
* Move a file and rewrite the project's references to it.
|
|
308
|
+
*
|
|
309
|
+
* `from` and `to` are project-relative, and so is every path in the report — including
|
|
310
|
+
* `references.files[].path` and the `errors[].path` naming a document the refactor could not
|
|
311
|
+
* write. An adapter over a backend with a different path space translates the REQUEST or the
|
|
312
|
+
* REPLY, never both: which one needs translating is a property of that backend's route, and doing
|
|
313
|
+
* both silently assumes it echoes its own input space (specs/desktop.md §3.1).
|
|
314
|
+
*/
|
|
269
315
|
renameFile: (from: string, to: string) => Promise<RenameResult>;
|
|
270
316
|
/**
|
|
271
317
|
* Where a file or a component tag is used across the project — the read side of the same walker
|
|
@@ -279,6 +325,9 @@ export interface StudioPlatform {
|
|
|
279
325
|
*
|
|
280
326
|
* At least one of `path` / `tagName` must be given. A `path` naming a component document
|
|
281
327
|
* contributes its own root tag, so one call answers "as a file AND as an element" together.
|
|
328
|
+
*
|
|
329
|
+
* `path` is project-relative going in, and every path coming back is too. A backend that cannot
|
|
330
|
+
* place the target inside the active project MUST fail rather than answer zero.
|
|
282
331
|
*/
|
|
283
332
|
findReferences?: (target: { path?: string; tagName?: string }) => Promise<ReferencesResult>;
|
|
284
333
|
createDirectory: (path: string) => Promise<void>;
|
|
@@ -298,8 +347,12 @@ export interface StudioPlatform {
|
|
|
298
347
|
installDependencies?: () => Promise<PackageOpResult>;
|
|
299
348
|
/** Whether the project has uninstalled dependencies (node_modules missing). */
|
|
300
349
|
dependenciesNeedInstall?: () => Promise<boolean>;
|
|
301
|
-
/**
|
|
302
|
-
|
|
350
|
+
/**
|
|
351
|
+
* The newest version published on the registry for each dependency, whether or not it is behind.
|
|
352
|
+
* Optional: a platform that cannot reach a registry omits it, and Studio shows no Latest column
|
|
353
|
+
* values rather than inventing them.
|
|
354
|
+
*/
|
|
355
|
+
packageVersions?: () => Promise<PackageVersionInfo[]>;
|
|
303
356
|
/**
|
|
304
357
|
* Rewrite the version range of each named package (preserving its dependencies/devDependencies
|
|
305
358
|
* placement) and run `bun install`. Used by the @jxsuite bump and per-dependency updates.
|
|
@@ -389,6 +442,31 @@ export interface StudioPlatform {
|
|
|
389
442
|
* build happened to leave — which for most projects is nothing at all.
|
|
390
443
|
*/
|
|
391
444
|
buildSite?: () => Promise<SiteBuildResult>;
|
|
445
|
+
/**
|
|
446
|
+
* Preview the site LIVE at `route`, with no build on the path.
|
|
447
|
+
*
|
|
448
|
+
* The sibling of {@link buildSite} and what `View: Open in Browser` reaches first. Nothing is
|
|
449
|
+
* compiled: the backend composes the working tree on demand and the runtime assembles each page
|
|
450
|
+
* in the reader's browser, so what opens carries edits nobody has saved. `buildSite` keeps its
|
|
451
|
+
* own meaning and its own command, because "what does this page look like at its real URL?" and
|
|
452
|
+
* "does my site build?" are different questions and only one of them wants to wait.
|
|
453
|
+
*
|
|
454
|
+
* Optional, and the fallback is `buildSite`: a backend that predates this keeps working, and a
|
|
455
|
+
* backend that can do neither says so rather than opening an origin where half the site is the
|
|
456
|
+
* wrong file.
|
|
457
|
+
*/
|
|
458
|
+
previewSite?: (opts: { route: string }) => Promise<SitePreviewResult>;
|
|
459
|
+
/**
|
|
460
|
+
* Publish the bytes a save WOULD write for one document, so a live preview shows the canvas
|
|
461
|
+
* rather than the disk.
|
|
462
|
+
*
|
|
463
|
+
* Bytes rather than a document, and the same bytes `writeFile` would receive: that is what makes
|
|
464
|
+
* "what the reader sees" and "what saving would produce" one answer instead of two that drift. A
|
|
465
|
+
* backend holds them in memory and writes them nowhere.
|
|
466
|
+
*/
|
|
467
|
+
setPreviewOverlay?: (path: string, contents: string) => Promise<void>;
|
|
468
|
+
/** Drop one document's unsaved bytes, or every one of this project's when no path is given. */
|
|
469
|
+
clearPreviewOverlay?: (path?: string) => Promise<void>;
|
|
392
470
|
gitAddRemote: (name: string, url: string) => Promise<void>;
|
|
393
471
|
/**
|
|
394
472
|
* How the New Project modal collects a destination, and which `CreateProjectDestination` variant
|
|
@@ -429,12 +507,19 @@ export interface StudioPlatform {
|
|
|
429
507
|
* AI-guided import of an existing site into a new project. Runs in the backend (headless Chrome +
|
|
430
508
|
* fs), streaming progress until the project is written. Absent on platforms without a backend
|
|
431
509
|
* import pipeline — the New Project modal hides its Import tab then.
|
|
510
|
+
*
|
|
511
|
+
* `onReady` fires the moment the destination holds an openable `project.json` — seconds in, where
|
|
512
|
+
* the resolved promise is minutes away on a real crawl. It is what lets the caller OPEN the
|
|
513
|
+
* project and let its file tree fill up while the run continues, instead of showing a welcome
|
|
514
|
+
* screen throughout. Optional on both sides: a backend that never sends the line simply never
|
|
515
|
+
* calls it, and the caller opens the project when the run ends, as it always did.
|
|
432
516
|
*/
|
|
433
517
|
importSite?: (
|
|
434
518
|
opts: ImportSiteOptions,
|
|
435
519
|
onProgress: (evt: ImportProgressEvent) => void,
|
|
436
520
|
signal?: AbortSignal,
|
|
437
|
-
|
|
521
|
+
onReady?: (evt: ImportReadyEvent) => void,
|
|
522
|
+
) => Promise<{ root: string; config: ProjectConfig; result?: ImportSiteSummary }>;
|
|
438
523
|
/**
|
|
439
524
|
* Open a native directory picker and return the chosen absolute path (null when cancelled). Backs
|
|
440
525
|
* the New Project modal's **Browse…** button on `createDestination: "path"` platforms. Desktop
|
|
@@ -568,6 +653,19 @@ export interface StudioPlatform {
|
|
|
568
653
|
* showing a stale value until its next boot rather than losing anything.
|
|
569
654
|
*/
|
|
570
655
|
subscribeSettings?: (handler: (settings: Record<string, string>) => void) => () => void;
|
|
656
|
+
/**
|
|
657
|
+
* Watch the backend connection, on platforms whose transport can lose one.
|
|
658
|
+
*
|
|
659
|
+
* Only a socket-based launcher implements this: the dev server talks over `fetch` and has nothing
|
|
660
|
+
* to lose, and the electrobun launcher's RPC times a request out rather than dropping it. The
|
|
661
|
+
* chromium launcher holds one long-lived WebSocket, and when it died every call after it hung
|
|
662
|
+
* with no error anywhere — so the transport reports the state, and the studio says it out loud.
|
|
663
|
+
*
|
|
664
|
+
* The handler is called on each transition, never repeatedly for one state.
|
|
665
|
+
*/
|
|
666
|
+
subscribeConnection?: (
|
|
667
|
+
handler: (state: { online: boolean; reason?: string }) => void,
|
|
668
|
+
) => () => void;
|
|
571
669
|
}
|
|
572
670
|
|
|
573
671
|
// ─── Studio Types ───────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Ai-model-picker.ts — the provider's model catalogue as one `sp-picker`, for every surface that
|
|
4
|
+
* chooses a model.
|
|
5
|
+
*
|
|
6
|
+
* It was private to `panels/ai-chat/composer.ts`, which was correct while the chat composer was the
|
|
7
|
+
* only place a model could be chosen. The New Project **Import** source now chooses one too — and
|
|
8
|
+
* that run spends more tokens than any single chat turn — so the choice needed a second host. A
|
|
9
|
+
* copy would have been two implementations of the invariant below, which is exactly the kind that
|
|
10
|
+
* only breaks in the copy nobody looked at.
|
|
11
|
+
*
|
|
12
|
+
* **The list is never held privately.** It is read from `cachedModels(credentials)` on every
|
|
13
|
+
* render, so a list can only ever be shown for the credentials it was listed under: change the key
|
|
14
|
+
* and the catalogue becomes UNAVAILABLE rather than stale. Holding it in a closure is what once let
|
|
15
|
+
* the picker offer one provider's models while another was configured.
|
|
16
|
+
*
|
|
17
|
+
* A closure factory (precedent: `createComposer`, `createAiCredentialsForm`) so two hosts never
|
|
18
|
+
* share a loading flag or a failed-fetch record.
|
|
19
|
+
*
|
|
20
|
+
* @license MIT
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { html, nothing } from "lit-html";
|
|
24
|
+
import { live } from "lit-html/directives/live.js";
|
|
25
|
+
import {
|
|
26
|
+
aiConnection,
|
|
27
|
+
cachedModels,
|
|
28
|
+
fetchAvailableModels,
|
|
29
|
+
preferredModel,
|
|
30
|
+
} from "../services/ai-models";
|
|
31
|
+
import { setModel } from "../services/ai-settings";
|
|
32
|
+
|
|
33
|
+
import type { TemplateResult } from "lit-html";
|
|
34
|
+
import type { AiCredentials } from "../services/ai-models";
|
|
35
|
+
|
|
36
|
+
/** The `sp-menu-item` value that means "try listing again" rather than "select this model". */
|
|
37
|
+
export const RETRY_MODELS = "__retry_models__";
|
|
38
|
+
|
|
39
|
+
/** The `sp-menu-item` value of the disabled placeholder shown during the first fetch. */
|
|
40
|
+
export const LOADING_MODELS = "__loading__";
|
|
41
|
+
|
|
42
|
+
export interface ModelPickerOptions {
|
|
43
|
+
/** Host re-render scheduler — called whenever a fetch settles. */
|
|
44
|
+
requestRender: () => void;
|
|
45
|
+
/**
|
|
46
|
+
* The model to show as selected. Defaults to the stored/proxy-preferred one, which is what the
|
|
47
|
+
* chat composer wants; the Import form passes its own draft, which is not a stored setting until
|
|
48
|
+
* the run starts.
|
|
49
|
+
*/
|
|
50
|
+
getModel?: () => string;
|
|
51
|
+
/**
|
|
52
|
+
* Where a choice goes. Defaults to `setModel` (the roaming application preference). The Import
|
|
53
|
+
* form passes its own setter, because choosing a model for one import must not silently retarget
|
|
54
|
+
* the assistant.
|
|
55
|
+
*/
|
|
56
|
+
onChange?: (id: string) => void;
|
|
57
|
+
/** Extra class on the `sp-picker`, so a host can size it without touching this module. */
|
|
58
|
+
className?: string;
|
|
59
|
+
/** Picker size; `"s"` (the composer's) by default. */
|
|
60
|
+
size?: "s" | "m";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ModelPicker {
|
|
64
|
+
render: () => TemplateResult;
|
|
65
|
+
/** Whether a fetch is in flight — for a host that wants to disable a submit button. */
|
|
66
|
+
isLoading: () => boolean;
|
|
67
|
+
/** The last fetch's failure, or `""`. */
|
|
68
|
+
error: () => string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Create a model picker bound to a host's render scheduler.
|
|
73
|
+
*
|
|
74
|
+
* @param {ModelPickerOptions} opts
|
|
75
|
+
* @returns {ModelPicker}
|
|
76
|
+
*/
|
|
77
|
+
export function createModelPicker(opts: ModelPickerOptions): ModelPicker {
|
|
78
|
+
const getModel = opts.getModel ?? preferredModel;
|
|
79
|
+
const onChange = opts.onChange ?? setModel;
|
|
80
|
+
|
|
81
|
+
let loading = false;
|
|
82
|
+
let failure = "";
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The credentials the last fetch was made FOR, so a failure is not retried on every render.
|
|
86
|
+
*
|
|
87
|
+
* The list itself is deliberately not held here — see the module docblock.
|
|
88
|
+
*/
|
|
89
|
+
let attempted: AiCredentials | null = null;
|
|
90
|
+
|
|
91
|
+
function sameConnection(a: AiCredentials | null, b: AiCredentials): boolean {
|
|
92
|
+
return a !== null && a.apiKey === b.apiKey && a.baseUrl === b.baseUrl;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function ensureModels(force = false) {
|
|
96
|
+
const credentials = aiConnection();
|
|
97
|
+
const settled = cachedModels(credentials) !== null || sameConnection(attempted, credentials);
|
|
98
|
+
if (loading || (settled && !force)) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
loading = true;
|
|
102
|
+
failure = "";
|
|
103
|
+
attempted = credentials;
|
|
104
|
+
fetchAvailableModels({ credentials, force })
|
|
105
|
+
.catch((error: unknown) => {
|
|
106
|
+
failure = (error as Error).message || "Failed to fetch models";
|
|
107
|
+
})
|
|
108
|
+
.finally(() => {
|
|
109
|
+
loading = false;
|
|
110
|
+
opts.requestRender();
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function onPickerChange(e: Event) {
|
|
115
|
+
const { value } = e.target as HTMLInputElement;
|
|
116
|
+
if (value === RETRY_MODELS) {
|
|
117
|
+
attempted = null;
|
|
118
|
+
ensureModels(true);
|
|
119
|
+
// Re-render so the picker snaps back to the current model instead of showing "Retry".
|
|
120
|
+
opts.requestRender();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (value && value !== LOADING_MODELS) {
|
|
124
|
+
onChange(value);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function render(): TemplateResult {
|
|
129
|
+
ensureModels();
|
|
130
|
+
const current = getModel();
|
|
131
|
+
const listed = cachedModels(aiConnection()) ?? [];
|
|
132
|
+
/* A catalogue that does not contain the current model still has to show it selected — an
|
|
133
|
+
unlisted id is the normal case for a self-hosted or newly released model. */
|
|
134
|
+
const items = listed.some((m) => m.id === current)
|
|
135
|
+
? listed
|
|
136
|
+
: [{ id: current, name: current }, ...listed];
|
|
137
|
+
return html`
|
|
138
|
+
<sp-picker
|
|
139
|
+
size=${opts.size ?? "s"}
|
|
140
|
+
quiet
|
|
141
|
+
class=${opts.className ?? "ai-model-picker"}
|
|
142
|
+
title=${failure ? `Couldn't load models: ${failure}` : "Model"}
|
|
143
|
+
.value=${live(current)}
|
|
144
|
+
@change=${onPickerChange}
|
|
145
|
+
>
|
|
146
|
+
${items.map((m) => html`<sp-menu-item value=${m.id}>${m.name}</sp-menu-item>`)}
|
|
147
|
+
${
|
|
148
|
+
loading
|
|
149
|
+
? html`<sp-menu-item disabled value=${LOADING_MODELS}>Loading models…</sp-menu-item>`
|
|
150
|
+
: nothing
|
|
151
|
+
}
|
|
152
|
+
${
|
|
153
|
+
failure
|
|
154
|
+
? html`<sp-menu-item value=${RETRY_MODELS}>Retry loading models</sp-menu-item>`
|
|
155
|
+
: nothing
|
|
156
|
+
}
|
|
157
|
+
</sp-picker>
|
|
158
|
+
`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { error: () => failure, isLoading: () => loading, render };
|
|
162
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `choiceField` — one labelled choice, rendered the way every Spectrum picker in Studio is.
|
|
3
|
+
*
|
|
4
|
+
* It exists so the New File dialog's format picker and the Convert dialog's target picker are the
|
|
5
|
+
* SAME control rather than two that drift: same divider idiom for a sentinel row, same `live()`
|
|
6
|
+
* guard, same "one kind is not a choice" collapse to static text.
|
|
7
|
+
*
|
|
8
|
+
* **It imports lit only.** Both callers live in modules the other imports — `ui/layers.ts` is
|
|
9
|
+
* imported by `format/convert-file.ts`, which also wants this fragment — so a shared control that
|
|
10
|
+
* reached into either would be a cycle, and `import/no-cycle` is an error here.
|
|
11
|
+
*
|
|
12
|
+
* `.value=${live(...)}` is not stylistic. `sp-picker` writes its own `value` when the reader picks
|
|
13
|
+
* a row, so lit's dirty check sees the value it last committed and skips the re-commit that would
|
|
14
|
+
* put the control back where the model says it is. `scripts/check-lit-conventions.ts` fails on the
|
|
15
|
+
* unguarded form.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { html, nothing } from "lit-html";
|
|
19
|
+
import { live } from "lit-html/directives/live.js";
|
|
20
|
+
import type { TemplateResult } from "lit-html";
|
|
21
|
+
|
|
22
|
+
/** One row of a {@link choiceField}. */
|
|
23
|
+
export interface ChoiceOption {
|
|
24
|
+
value: string;
|
|
25
|
+
label: string;
|
|
26
|
+
/** Draw an `sp-menu-divider` immediately before this row — how a sentinel row is set apart. */
|
|
27
|
+
dividerBefore?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What a {@link choiceField} renders and reports. */
|
|
31
|
+
export interface ChoiceSpec {
|
|
32
|
+
label: string;
|
|
33
|
+
options: readonly ChoiceOption[];
|
|
34
|
+
value: string;
|
|
35
|
+
onChange: (next: string) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A labelled picker, or static text when there is nothing to choose.
|
|
40
|
+
*
|
|
41
|
+
* The collapse is the rule `panels/pane-context.ts` already states about editor kinds: **one kind
|
|
42
|
+
* is not a choice**. A picker whose menu holds a single row is a control that cannot do anything,
|
|
43
|
+
* and drawing it invites the reader to open it and find out.
|
|
44
|
+
*
|
|
45
|
+
* @param spec - The label, the rows, the current value and the change sink.
|
|
46
|
+
* @returns The fragment, for a caller's own template.
|
|
47
|
+
*/
|
|
48
|
+
export function choiceField(spec: ChoiceSpec): TemplateResult {
|
|
49
|
+
const current = spec.options.find((option) => option.value === spec.value);
|
|
50
|
+
if (spec.options.length < 2) {
|
|
51
|
+
return html`<div class="choice-row">
|
|
52
|
+
<sp-field-label size="s">${spec.label}</sp-field-label>
|
|
53
|
+
<span class="choice-static">${current?.label ?? spec.value}</span>
|
|
54
|
+
</div>`;
|
|
55
|
+
}
|
|
56
|
+
return html`<div class="choice-row">
|
|
57
|
+
<sp-field-label size="s">${spec.label}</sp-field-label>
|
|
58
|
+
<sp-picker
|
|
59
|
+
size="s"
|
|
60
|
+
label=${spec.label}
|
|
61
|
+
.value=${live(spec.value)}
|
|
62
|
+
@change=${(e: Event) => spec.onChange((e.target as HTMLInputElement).value)}
|
|
63
|
+
>
|
|
64
|
+
${spec.options.map(
|
|
65
|
+
(option) =>
|
|
66
|
+
html`${
|
|
67
|
+
option.dividerBefore === true ? html`<sp-menu-divider></sp-menu-divider>` : nothing
|
|
68
|
+
}<sp-menu-item value=${option.value}>${option.label}</sp-menu-item>`,
|
|
69
|
+
)}
|
|
70
|
+
</sp-picker>
|
|
71
|
+
</div>`;
|
|
72
|
+
}
|
package/src/ui/layers.ts
CHANGED
|
@@ -15,10 +15,12 @@
|
|
|
15
15
|
import { html, render as litRender, nothing } from "lit-html";
|
|
16
16
|
import { ref } from "lit-html/directives/ref.js";
|
|
17
17
|
import { repeat } from "lit-html/directives/repeat.js";
|
|
18
|
+
import { choiceField } from "./choice-field";
|
|
18
19
|
import { overlayRegion, REGION_ATTR } from "./regions";
|
|
19
20
|
import { dismiss, toasts } from "../services/notify";
|
|
20
21
|
import { activeRegistry } from "../commands/active-registry";
|
|
21
22
|
import { effect, effectScope } from "../reactivity";
|
|
23
|
+
import type { ChoiceOption } from "./choice-field";
|
|
22
24
|
import type { Notification, Severity } from "../services/notify";
|
|
23
25
|
import type { EffectScope } from "@vue/reactivity";
|
|
24
26
|
import type { TemplateResult } from "lit-html";
|
|
@@ -338,6 +340,31 @@ export function showSaveDiscardDialog(
|
|
|
338
340
|
);
|
|
339
341
|
}
|
|
340
342
|
|
|
343
|
+
/**
|
|
344
|
+
* A picker rendered above the field, whose selection the dialog OWNS.
|
|
345
|
+
*
|
|
346
|
+
* Owned, not merely displayed: the choice reaches `validate` as its second argument, and picking a
|
|
347
|
+
* row re-runs it. That is the whole reason this lives here rather than being smuggled in through
|
|
348
|
+
* `message` — `check` and `rerender` are private to {@link showPromptDialog}, so a caller-built
|
|
349
|
+
* control could change the selection but could never make the field's refusal catch up with it. The
|
|
350
|
+
* New File dialog's format picker is exactly that case: switching from Markdown to JSON changes
|
|
351
|
+
* whether the typed name is already taken, with no keystroke to notice.
|
|
352
|
+
*/
|
|
353
|
+
export interface PromptChoice {
|
|
354
|
+
/** Label above the picker. */
|
|
355
|
+
label: string;
|
|
356
|
+
/**
|
|
357
|
+
* The rows, re-read on EVERY render rather than captured once. A snapshot taken when the options
|
|
358
|
+
* object is built freezes a list the project can still be loading — the same defect
|
|
359
|
+
* `content/entry-commands.ts`'s `derivedEnumProperty` exists to prevent one layer up.
|
|
360
|
+
*/
|
|
361
|
+
options: () => readonly ChoiceOption[];
|
|
362
|
+
/** Which row starts selected. */
|
|
363
|
+
initial: string;
|
|
364
|
+
/** Told what was picked, for a caller keeping its own derived state. */
|
|
365
|
+
onChange?: (next: string) => void;
|
|
366
|
+
}
|
|
367
|
+
|
|
341
368
|
/** Options accepted by {@link showPromptDialog}. */
|
|
342
369
|
export interface PromptDialogOptions {
|
|
343
370
|
/** Label on the confirming button. */
|
|
@@ -346,17 +373,21 @@ export interface PromptDialogOptions {
|
|
|
346
373
|
cancelLabel?: string;
|
|
347
374
|
/** Explanatory copy rendered above the text field. */
|
|
348
375
|
message?: string | TemplateResult;
|
|
349
|
-
/** Placeholder shown while the field is empty. */
|
|
350
|
-
placeholder?: string;
|
|
376
|
+
/** Placeholder shown while the field is empty. Read on every render, so it may follow the choice. */
|
|
377
|
+
placeholder?: string | (() => string);
|
|
351
378
|
/** How much of the pre-filled value to select once the field takes focus. */
|
|
352
379
|
select?: "all" | "stem" | "none";
|
|
353
380
|
/**
|
|
354
381
|
* Validate the raw field value. Return an empty string when valid, or a message to show as
|
|
355
382
|
* negative help text (which also blocks confirmation). Defaults to "must not be blank".
|
|
383
|
+
*
|
|
384
|
+
* `chosen` is the {@link PromptChoice} selection, or `""` when the dialog offers no choice.
|
|
356
385
|
*/
|
|
357
|
-
validate?: (value: string) => string;
|
|
386
|
+
validate?: (value: string, chosen: string) => string;
|
|
358
387
|
/** Pre-filled value. */
|
|
359
388
|
value?: string;
|
|
389
|
+
/** A picker above the field whose selection this dialog owns. */
|
|
390
|
+
choice?: PromptChoice;
|
|
360
391
|
}
|
|
361
392
|
|
|
362
393
|
/**
|
|
@@ -375,6 +406,7 @@ export function showPromptDialog(
|
|
|
375
406
|
): Promise<string | null> {
|
|
376
407
|
const {
|
|
377
408
|
cancelLabel = "Cancel",
|
|
409
|
+
choice,
|
|
378
410
|
confirmLabel = "OK",
|
|
379
411
|
message,
|
|
380
412
|
placeholder = "",
|
|
@@ -384,12 +416,22 @@ export function showPromptDialog(
|
|
|
384
416
|
} = opts;
|
|
385
417
|
|
|
386
418
|
const check = (candidate: string) =>
|
|
387
|
-
validate ? validate(candidate) : candidate.trim() ? "" : "Enter a value.";
|
|
419
|
+
validate ? validate(candidate, chosen) : candidate.trim() ? "" : "Enter a value.";
|
|
388
420
|
|
|
389
421
|
let value = initialValue;
|
|
422
|
+
let chosen = choice?.initial ?? "";
|
|
390
423
|
let error = "";
|
|
391
424
|
let wrapperEl: HTMLElement | null = null;
|
|
392
425
|
let focusRequested = false;
|
|
426
|
+
/*
|
|
427
|
+
* Whether the reader has typed yet.
|
|
428
|
+
*
|
|
429
|
+
* A dialog opens with a valid prefill and NO error, and picking a format must not be the thing
|
|
430
|
+
* that first paints one under a field nobody has touched — "untitled" is not yet a mistake. So a
|
|
431
|
+
* pick refreshes an error that is already showing and never mints the first one; the confirm
|
|
432
|
+
* still refuses, because `confirm()` runs `check` unconditionally.
|
|
433
|
+
*/
|
|
434
|
+
let touched = false;
|
|
393
435
|
|
|
394
436
|
return showDialog<string | null>((done) => {
|
|
395
437
|
function rerender() {
|
|
@@ -411,6 +453,7 @@ export function showPromptDialog(
|
|
|
411
453
|
}
|
|
412
454
|
|
|
413
455
|
function onInput(e: Event) {
|
|
456
|
+
touched = true;
|
|
414
457
|
value = (e.target as HTMLInputElement).value || "";
|
|
415
458
|
const next = check(value);
|
|
416
459
|
if (next !== error) {
|
|
@@ -419,6 +462,23 @@ export function showPromptDialog(
|
|
|
419
462
|
}
|
|
420
463
|
}
|
|
421
464
|
|
|
465
|
+
/**
|
|
466
|
+
* Take a pick, and re-render UNCONDITIONALLY.
|
|
467
|
+
*
|
|
468
|
+
* `onInput` re-renders only when the error string changed, which is right for a keystroke: the
|
|
469
|
+
* template it would rebuild is identical. A pick is not that. It can change the placeholder,
|
|
470
|
+
* the picker's own selected row, and — for the New File dialog — whether the composed filename
|
|
471
|
+
* is already taken, all with the error text unchanged. Comparing error strings here would leave
|
|
472
|
+
* a dialog showing "about.md already exists" after the reader switched the format to JSON.
|
|
473
|
+
*/
|
|
474
|
+
function onPick(next: string) {
|
|
475
|
+
chosen = next;
|
|
476
|
+
choice?.onChange?.(next);
|
|
477
|
+
const candidate = check(value);
|
|
478
|
+
error = touched || error ? candidate : "";
|
|
479
|
+
rerender();
|
|
480
|
+
}
|
|
481
|
+
|
|
422
482
|
function onKeydown(e: KeyboardEvent) {
|
|
423
483
|
if (e.key === "Enter") {
|
|
424
484
|
confirm();
|
|
@@ -472,9 +532,27 @@ export function showPromptDialog(
|
|
|
472
532
|
// Spectrum resets <p> margins to 0, so without this the copy sits flush on the field.
|
|
473
533
|
message ? html`<p style="margin:0 0 8px">${message}</p>` : nothing
|
|
474
534
|
}
|
|
535
|
+
${
|
|
536
|
+
/*
|
|
537
|
+
* Above the field, and in ONE template position in every mode.
|
|
538
|
+
*
|
|
539
|
+
* The `sp-textfield` below must never move or be rebuilt by a conditional branch: lit
|
|
540
|
+
* would commit a new element, `onFieldRef` would fire again, the `focusRequested` latch
|
|
541
|
+
* would refuse to re-focus it, and the caret would be stranded mid-name. Only the
|
|
542
|
+
* placeholder, the validation rules and the help text vary between modes.
|
|
543
|
+
*/
|
|
544
|
+
choice
|
|
545
|
+
? choiceField({
|
|
546
|
+
label: choice.label,
|
|
547
|
+
onChange: onPick,
|
|
548
|
+
options: choice.options(),
|
|
549
|
+
value: chosen,
|
|
550
|
+
})
|
|
551
|
+
: nothing
|
|
552
|
+
}
|
|
475
553
|
<sp-textfield
|
|
476
554
|
style="width:100%"
|
|
477
|
-
placeholder=${placeholder}
|
|
555
|
+
placeholder=${typeof placeholder === "function" ? placeholder() : placeholder}
|
|
478
556
|
value=${value}
|
|
479
557
|
?invalid=${Boolean(error)}
|
|
480
558
|
@input=${onInput}
|