@jxsuite/studio 1.3.0 → 1.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/studio",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Jx Studio — visual builder for Jx documents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -11,7 +11,10 @@
11
11
  "files": [
12
12
  "*.js",
13
13
  "src",
14
- "dist"
14
+ "dist",
15
+ "index.html",
16
+ "canvas.html",
17
+ "fonts"
15
18
  ],
16
19
  "type": "module",
17
20
  "exports": {
@@ -38,14 +41,14 @@
38
41
  "dependencies": {
39
42
  "@atlaskit/pragmatic-drag-and-drop": "^2.0.1",
40
43
  "@atlaskit/pragmatic-drag-and-drop-hitbox": "^2.0.0",
41
- "@jxsuite/ai": "^0.33.0",
42
- "@jxsuite/collab": "^0.4.1",
43
- "@jxsuite/create": "^1.1.1",
44
- "@jxsuite/formulas": "^0.0.3",
45
- "@jxsuite/markup": "^0.3.1",
46
- "@jxsuite/protocol": "^0.5.1",
47
- "@jxsuite/runtime": "^1.2.0",
48
- "@jxsuite/schema": "^1.2.0",
44
+ "@jxsuite/ai": "^0.34.0",
45
+ "@jxsuite/collab": "^0.5.1",
46
+ "@jxsuite/create": "^1.2.1",
47
+ "@jxsuite/formulas": "^0.0.5",
48
+ "@jxsuite/markup": "^0.4.1",
49
+ "@jxsuite/protocol": "^0.6.1",
50
+ "@jxsuite/runtime": "^1.3.1",
51
+ "@jxsuite/schema": "^1.4.0",
49
52
  "@spectrum-web-components/accordion": "^1.12.2",
50
53
  "@spectrum-web-components/action-bar": "1.12.2",
51
54
  "@spectrum-web-components/action-button": "^1.12.2",
@@ -93,7 +96,7 @@
93
96
  "yaml": "^2.9.0"
94
97
  },
95
98
  "devDependencies": {
96
- "@happy-dom/global-registrator": "^20.10.6",
99
+ "@happy-dom/global-registrator": "^20.11.1",
97
100
  "@types/mdast": "^4.0.4",
98
101
  "@webref/css": "^8.7.0",
99
102
  "@webref/elements": "^2.7.1",
@@ -204,7 +204,7 @@ function promptComponentName(defaultName: string) {
204
204
  <sp-textfield
205
205
  placeholder="my-component"
206
206
  value=${value}
207
- ?negative=${Boolean(error)}
207
+ ?invalid=${Boolean(error)}
208
208
  @input=${onInput}
209
209
  @keydown=${onKeydown}
210
210
  ${ref((el) => {
@@ -200,7 +200,7 @@ async function promptRepeaterConfig(defs: Record<string, unknown>) {
200
200
  size="s"
201
201
  placeholder="myItems"
202
202
  .value=${newDefName}
203
- ?negative=${Boolean(error)}
203
+ ?invalid=${Boolean(error)}
204
204
  @input=${(e: Event) => {
205
205
  newDefName = (e.target as HTMLInputElement).value || "";
206
206
  error = "";
@@ -1,20 +1,29 @@
1
1
  /// <reference lib="dom" />
2
2
  /**
3
- * Add Existing Repository modal — a filterable picker over `platform.listRepos` (every repo the
4
- * platform's account link can reach, personal and organization). Choosing a repo runs
5
- * `platform.importProject`, which adopts it as a Jx project (probes project.json, tags + catalogues
6
- * it) and resolves with the catalogue root key; the caller opens it through the same path as a
7
- * recent project. Repos without a project.json fail with the backend's structured message, shown
8
- * inline.
3
+ * Repository picker modal — a filterable picker over `platform.listRepos` (every repo the
4
+ * platform's account link can reach, personal and organization). Two modes share the dialog:
5
+ *
6
+ * - "add" (Add Existing Repository): the unfiltered adoption path.
7
+ * - "open" (Open Project on `openProjectPicker: "repo-list"` platforms): only write-access
8
+ * repositories, Jx-tagged ones first.
9
+ *
10
+ * Choosing a repo runs `platform.importProject`, which adopts it as a Jx project (probes
11
+ * project.json, tags + catalogues it) and resolves with the catalogue root key; the caller opens it
12
+ * through the same path as a recent project. Repos without a project.json fail with the backend's
13
+ * structured message, shown inline.
9
14
  */
10
15
 
11
16
  import { html, nothing } from "lit-html";
12
17
  import { errorMessage } from "@jxsuite/schema/parse";
18
+ import { getAccountStatus, needsAppInstall } from "../account-status";
13
19
  import { getPlatform } from "../platform";
14
20
  import { openModal } from "../ui/layers";
15
21
  import type { RepoInfo } from "../types";
16
22
 
23
+ type PickerMode = "add" | "open";
24
+
17
25
  let _handle: ReturnType<typeof openModal> | null = null;
26
+ let _mode: PickerMode = "add";
18
27
  let _repos: RepoInfo[] | null = null;
19
28
  let _filter = "";
20
29
  let _error = "";
@@ -28,11 +37,29 @@ export function platformSupportsAddRepo(): boolean {
28
37
  return typeof platform.listRepos === "function" && typeof platform.importProject === "function";
29
38
  }
30
39
 
31
- /** Open the picker. Resolves with the imported project's catalogue root key, or null when cancelled. */
40
+ /** True when the active platform routes Open Project through this repo picker. */
41
+ export function platformUsesRepoPicker(): boolean {
42
+ return getPlatform().openProjectPicker === "repo-list" && platformSupportsAddRepo();
43
+ }
44
+
45
+ /**
46
+ * Open the adoption picker. Resolves with the imported project's catalogue root key, or null when
47
+ * cancelled.
48
+ */
32
49
  export function openAddRepoModal(): Promise<{ root: string } | null> {
50
+ return openPicker("add");
51
+ }
52
+
53
+ /** Open Project as a repo picker (write-access repositories only). Null when cancelled. */
54
+ export function openProjectPickerModal(): Promise<{ root: string } | null> {
55
+ return openPicker("open");
56
+ }
57
+
58
+ function openPicker(mode: PickerMode): Promise<{ root: string } | null> {
33
59
  if (_handle) {
34
60
  return Promise.resolve(null);
35
61
  }
62
+ _mode = mode;
36
63
  _repos = null;
37
64
  _filter = "";
38
65
  _error = "";
@@ -104,7 +131,13 @@ async function chooseRepo(repo: RepoInfo) {
104
131
 
105
132
  function visibleRepos(): RepoInfo[] {
106
133
  const query = _filter.trim().toLowerCase();
107
- const repos = _repos ?? [];
134
+ let repos = _repos ?? [];
135
+ if (_mode === "open") {
136
+ // Open Project offers only repos the user can write to; Jx-tagged repos surface first.
137
+ // The topic is an accelerator, not ground truth — untagged repos still open via importProject.
138
+ const writable = repos.filter((r) => r.permission === "admin" || r.permission === "write");
139
+ repos = [...writable.filter((r) => r.isJxProject), ...writable.filter((r) => !r.isJxProject)];
140
+ }
108
141
  return query ? repos.filter((r) => r.fullName.toLowerCase().includes(query)) : repos;
109
142
  }
110
143
 
@@ -129,17 +162,43 @@ function repoRowTpl(repo: RepoInfo) {
129
162
  `;
130
163
  }
131
164
 
165
+ function emptyTpl() {
166
+ if (_filter) {
167
+ return html`<div class="add-repo-empty">No repositories match the filter.</div>`;
168
+ }
169
+ if (_mode === "open" && (_repos ?? []).length > 0) {
170
+ return html`<div class="add-repo-empty">
171
+ No repositories with write access. Widen the Jx Suite GitHub App's repository access, or ask a
172
+ repository admin for write access.
173
+ </div>`;
174
+ }
175
+ if (needsAppInstall()) {
176
+ return html`<div class="add-repo-empty">
177
+ No repositories are reachable yet.
178
+ <a
179
+ class="add-repo-install"
180
+ href=${getAccountStatus()?.appInstallUrl ?? "#"}
181
+ target="_blank"
182
+ rel="noreferrer"
183
+ >
184
+ Install the Jx Suite GitHub App
185
+ </a>
186
+ to grant repository access, then reopen this dialog.
187
+ </div>`;
188
+ }
189
+ return html`<div class="add-repo-empty">
190
+ No repositories are reachable. Install the GitHub App (or widen its repository access) and try
191
+ again.
192
+ </div>`;
193
+ }
194
+
132
195
  function bodyTpl() {
133
196
  if (_repos === null) {
134
197
  return html`<div class="add-repo-empty">Loading repositories…</div>`;
135
198
  }
136
199
  const repos = visibleRepos();
137
200
  if (repos.length === 0) {
138
- return html`<div class="add-repo-empty">
139
- ${_filter
140
- ? "No repositories match the filter."
141
- : "No repositories are reachable. Install the GitHub App (or widen its repository access) and try again."}
142
- </div>`;
201
+ return emptyTpl();
143
202
  }
144
203
  return html`<div class="add-repo-list">${repos.map((repo) => repoRowTpl(repo))}</div>`;
145
204
  }
@@ -156,7 +215,9 @@ function renderModal() {
156
215
  }}
157
216
  >
158
217
  <div class="new-project-modal-header">
159
- <h2 class="new-project-modal-title">Add existing repository</h2>
218
+ <h2 class="new-project-modal-title">
219
+ ${_mode === "open" ? "Open Project" : "Add existing repository"}
220
+ </h2>
160
221
  <sp-action-button quiet size="s" @click=${closeAddRepoModal} title="Close">
161
222
  <sp-icon-close slot="icon"></sp-icon-close>
162
223
  </sp-action-button>
@@ -58,6 +58,12 @@ let _paramsSeededFor = "";
58
58
 
59
59
  let _error = "";
60
60
 
61
+ /**
62
+ * Inline validation error shown at the Project Name field (the global strip is for backend
63
+ * failures).
64
+ */
65
+ let _nameError = "";
66
+
61
67
  /** GitHub-App install link carried by a structured needs_installation_access failure. */
62
68
  let _errorInstallUrl = "";
63
69
 
@@ -66,6 +72,15 @@ function captureError(error: unknown) {
66
72
  _errorInstallUrl = installUrlOf(error) ?? "";
67
73
  }
68
74
 
75
+ /** After a failed submit, bring the params step back to the top and focus the name field. */
76
+ function focusNameField() {
77
+ requestAnimationFrame(() => {
78
+ const body = document.querySelector(".new-project-modal-body");
79
+ body?.scrollTo?.(0, 0);
80
+ body?.querySelector<HTMLElement>("sp-textfield")?.focus?.();
81
+ });
82
+ }
83
+
69
84
  let _creating = false;
70
85
 
71
86
  /** Starter templates offered in the picker (empty until loaded / on platforms without starters). */
@@ -99,7 +114,7 @@ function credsForm() {
99
114
  *
100
115
  * @returns {Promise<{ root: string; config: object } | null>}
101
116
  */
102
- export function openNewProjectModal(): Promise<{
117
+ export function openNewProjectModal(options?: { tab?: "template" | "starter" }): Promise<{
103
118
  root: string;
104
119
  config: ProjectConfig;
105
120
  } | null> {
@@ -113,13 +128,14 @@ export function openNewProjectModal(): Promise<{
113
128
  name: "",
114
129
  url: "",
115
130
  };
116
- _tab = "template";
131
+ _tab = options?.tab ?? "template";
117
132
  _step = "source";
118
133
  _template = "blank";
119
134
  _starter = "";
120
135
  _agentPrompt = "";
121
136
  _paramsSeededFor = "";
122
137
  _error = "";
138
+ _nameError = "";
123
139
  _errorInstallUrl = "";
124
140
  _creating = false;
125
141
  _starters = [];
@@ -215,6 +231,9 @@ function renderModal() {
215
231
  const onInput =
216
232
  (field: "name" | "description" | "url" | "adapter" | "directory") => (e: Event) => {
217
233
  _form[field] = (e.target as HTMLInputElement).value;
234
+ if (field === "name") {
235
+ _nameError = "";
236
+ }
218
237
  if (field === "name" && !_form.directory) {
219
238
  // Auto-derive directory slug from name while user hasn't manually typed one
220
239
  _dirDerived = true;
@@ -301,6 +320,7 @@ function renderModal() {
301
320
  }
302
321
  _step = "source";
303
322
  _error = "";
323
+ _nameError = "";
304
324
  renderModal();
305
325
  };
306
326
 
@@ -308,8 +328,9 @@ function renderModal() {
308
328
 
309
329
  const onSubmit = async () => {
310
330
  if (!_form.name.trim()) {
311
- _error = "Project name is required";
331
+ _nameError = "Project name is required";
312
332
  renderModal();
333
+ focusNameField();
313
334
  return;
314
335
  }
315
336
  if (!_form.directory.trim()) {
@@ -318,6 +339,7 @@ function renderModal() {
318
339
 
319
340
  _creating = true;
320
341
  _error = "";
342
+ _nameError = "";
321
343
  renderModal();
322
344
 
323
345
  try {
@@ -337,8 +359,9 @@ function renderModal() {
337
359
 
338
360
  const onAgentSubmit = async () => {
339
361
  if (!_form.name.trim()) {
340
- _error = "Project name is required";
362
+ _nameError = "Project name is required";
341
363
  renderModal();
364
+ focusNameField();
342
365
  return;
343
366
  }
344
367
  if (!_form.directory.trim()) {
@@ -347,6 +370,7 @@ function renderModal() {
347
370
 
348
371
  _creating = true;
349
372
  _error = "";
373
+ _nameError = "";
350
374
  renderModal();
351
375
 
352
376
  try {
@@ -468,9 +492,14 @@ function renderModal() {
468
492
  <sp-textfield
469
493
  placeholder="My Site"
470
494
  .value=${_form.name}
495
+ ?invalid=${Boolean(_nameError)}
471
496
  @input=${onInput("name")}
472
497
  style="width: 100%"
473
- ></sp-textfield>
498
+ >
499
+ ${_nameError
500
+ ? html`<sp-help-text slot="negative-help-text">${_nameError}</sp-help-text>`
501
+ : ""}
502
+ </sp-textfield>
474
503
  </label>
475
504
 
476
505
  <label class="new-project-field">
@@ -601,19 +630,17 @@ function renderModal() {
601
630
  </div>
602
631
  `
603
632
  : ""}
604
- <div class="new-project-modal-body">
605
- ${bodyTpl()}
606
- ${_tab !== "import" && _error
607
- ? html`<div class="new-project-error">
608
- ${_error}
609
- ${_errorInstallUrl
610
- ? html`<a href=${_errorInstallUrl} target="_blank" rel="noreferrer">
611
- Install the Jx Suite GitHub App →
612
- </a>`
613
- : ""}
614
- </div>`
615
- : ""}
616
- </div>
633
+ <div class="new-project-modal-body">${bodyTpl()}</div>
634
+ ${_tab !== "import" && _error
635
+ ? html`<div class="new-project-error new-project-error--global">
636
+ ${_error}
637
+ ${_errorInstallUrl
638
+ ? html`<a href=${_errorInstallUrl} target="_blank" rel="noreferrer">
639
+ Install the Jx Suite GitHub App →
640
+ </a>`
641
+ : ""}
642
+ </div>`
643
+ : ""}
617
644
  <div class="new-project-modal-footer">${footerTpl()}</div>
618
645
  </div>
619
646
  `;
@@ -15,7 +15,7 @@ import { platformSupportsClone } from "./git-panel";
15
15
  interface WelcomeCtx {
16
16
  openProject: () => void;
17
17
  openRecentProject: (root: string) => void;
18
- openNewProject: () => void;
18
+ openNewProject: (options?: { tab?: "starter" }) => void;
19
19
  cloneRepository: () => void;
20
20
  addExistingRepo: () => void;
21
21
  }
@@ -59,6 +59,20 @@ export function renderWelcome(host: HTMLElement) {
59
59
  </svg>
60
60
  New Project...
61
61
  </button>
62
+ <button class="welcome-action" @click=${() => ctx.openNewProject({ tab: "starter" })}>
63
+ <svg
64
+ class="welcome-action-icon"
65
+ width="16"
66
+ height="16"
67
+ viewBox="0 0 16 16"
68
+ fill="currentColor"
69
+ >
70
+ <path
71
+ d="M1 1h6v6H1V1zm1 1v4h4V2H2zm7-1h6v6H9V1zm1 1v4h4V2h-4zM1 9h6v6H1V9zm1 1v4h4v-4H2zm7-1h6v6H9V9zm1 1v4h4v-4h-4z"
72
+ />
73
+ </svg>
74
+ Start from an Example...
75
+ </button>
62
76
  <button class="welcome-action" @click=${() => ctx.openProject()}>
63
77
  <svg
64
78
  class="welcome-action-icon"
@@ -209,6 +209,9 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
209
209
  id: "cloud",
210
210
  projectRoot: root,
211
211
  canvasUrl: "/canvas.html",
212
+ /* Open Project routes through Studio's repo picker (write-access repos via listRepos +
213
+ importProject) — sessions are URL-bound, so openProject() below never opens a dialog. */
214
+ openProjectPicker: "repo-list",
212
215
 
213
216
  async activate() {
214
217
  if (!project) {
@@ -377,9 +380,8 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
377
380
 
378
381
  /**
379
382
  * The project's format registry, served by the session gateway (the dev server's `GET
380
- * /__studio/formats` under this session's base path). Part-4 cleanup: the cloud ProjectSession
381
- * does not serve the route yet, so this degrades to an empty registry (only .json documents
382
- * open) until the backend lands it.
383
+ * /__studio/formats` under this session's base path). Degrades to an empty registry (only .json
384
+ * documents open) against backends that predate the route.
383
385
  */
384
386
  async listFormats() {
385
387
  try {
package/src/studio.ts CHANGED
@@ -155,7 +155,11 @@ import { hydrateProjectList } from "./project-list";
155
155
  import { addRecentProject, hydrateRecentProjects, removeRecentProject } from "./recent-projects";
156
156
  import { hydrateSettings } from "./services/settings-store";
157
157
  import { initWelcome } from "./panels/welcome-screen";
158
- import { openAddRepoModal } from "./new-project/add-repo-modal";
158
+ import {
159
+ openAddRepoModal,
160
+ openProjectPickerModal,
161
+ platformUsesRepoPicker,
162
+ } from "./new-project/add-repo-modal";
159
163
  import { openNewProjectModal } from "./new-project/new-project-modal";
160
164
  import type { DocumentStackEntry, GitDiffState } from "./types";
161
165
  import type { Tab } from "./tabs/tab";
@@ -546,8 +550,8 @@ initWelcome({
546
550
  }
547
551
  },
548
552
  cloneRepository: () => cloneRepository({ openRecentProject }),
549
- openNewProject: async () => {
550
- const result = await openNewProjectModal();
553
+ openNewProject: async (options) => {
554
+ const result = await openNewProjectModal(options);
551
555
  if (result) {
552
556
  void openRecentProject(result.root);
553
557
  }
@@ -888,6 +892,19 @@ function loadProject() {
888
892
  return _loadProject();
889
893
  }
890
894
  async function openProject() {
895
+ // Repo-list platforms (cloud) pick from the user's writable repositories instead of a
896
+ // Backend dialog; the choice opens through the same path as a recent project.
897
+ if (platformUsesRepoPicker()) {
898
+ const picked = await openProjectPickerModal();
899
+ if (picked) {
900
+ // The catalogue may have gained an entry; refresh it before navigating into the project.
901
+ void hydrateProjectList().then(() => {
902
+ render();
903
+ });
904
+ void openRecentProject(picked.root);
905
+ }
906
+ return;
907
+ }
891
908
  const result = await _openProject({
892
909
  renderActivityBar: () => renderActivityBar(),
893
910
  renderLeftPanel,
package/src/types.ts CHANGED
@@ -127,6 +127,14 @@ export interface StudioPlatform {
127
127
  config: ProjectConfig;
128
128
  handle: { root: string; name: string; projectConfig: ProjectConfig };
129
129
  } | null>;
130
+ /**
131
+ * How "Open Project" picks a project. Absent: `openProject()` owns picking (native dialog /
132
+ * showDirectoryPicker). `"repo-list"`: Studio shows its repository picker over `listRepos` +
133
+ * `importProject` (write-access repositories) and opens the choice through the recent-projects
134
+ * path — `openProject()` is never called. Cloud sets this: its sessions are URL-bound, so there
135
+ * is no backend dialog to delegate to.
136
+ */
137
+ openProjectPicker?: "repo-list";
130
138
  probeRootProject: () => Promise<{
131
139
  meta: { root: string; name: string };
132
140
  info: {