@jxsuite/studio 0.37.0 → 1.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.
Files changed (38) hide show
  1. package/dist/studio.js +77654 -76269
  2. package/dist/studio.js.map +128 -119
  3. package/package.json +44 -44
  4. package/src/account-status.ts +39 -0
  5. package/src/browse/browse.ts +10 -14
  6. package/src/canvas/iframe-host.ts +1 -1
  7. package/src/editor/context-menu.ts +1 -1
  8. package/src/editor/repeater-scope.ts +8 -13
  9. package/src/files/files.ts +3 -0
  10. package/src/format/format-host.ts +63 -1
  11. package/src/new-project/add-repo-modal.ts +183 -0
  12. package/src/new-project/new-project-modal.ts +22 -3
  13. package/src/page-params.ts +34 -8
  14. package/src/panels/ai-chat/chat-markdown.ts +2 -2
  15. package/src/panels/ai-panel.ts +61 -4
  16. package/src/panels/data-grid.ts +619 -0
  17. package/src/panels/signals-panel.ts +102 -437
  18. package/src/panels/statusbar.ts +1 -1
  19. package/src/panels/welcome-screen.ts +50 -0
  20. package/src/platform-errors.ts +30 -0
  21. package/src/platforms/cloud.ts +172 -89
  22. package/src/platforms/devserver.ts +172 -0
  23. package/src/services/context-resolver.ts +73 -0
  24. package/src/services/data-service.ts +155 -0
  25. package/src/services/monaco-setup.ts +75 -26
  26. package/src/settings/contributed-section.ts +406 -0
  27. package/src/settings/extension-sections.ts +145 -0
  28. package/src/settings/schema-field-ui.ts +4 -2
  29. package/src/settings/settings-modal.ts +101 -42
  30. package/src/site-context.ts +10 -1
  31. package/src/studio.ts +24 -0
  32. package/src/tabs/transact.ts +1 -1
  33. package/src/types.ts +120 -1
  34. package/src/ui/form-controls.ts +322 -0
  35. package/src/ui/progress-modal.ts +2 -2
  36. package/src/ui/schema-form.ts +524 -0
  37. package/src/utils/studio-utils.ts +4 -3
  38. package/src/settings/content-types-editor.ts +0 -599
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/studio",
3
- "version": "0.37.0",
3
+ "version": "1.0.0",
4
4
  "description": "Jx Studio — visual builder for Jx documents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -39,48 +39,48 @@
39
39
  "@atlaskit/pragmatic-drag-and-drop": "^2.0.1",
40
40
  "@atlaskit/pragmatic-drag-and-drop-hitbox": "^2.0.0",
41
41
  "@jxsuite/ai": "^0.33.0",
42
- "@jxsuite/collab": "^0.2.0",
43
- "@jxsuite/create": "^0.36.0",
44
- "@jxsuite/parser": "^0.35.1",
45
- "@jxsuite/protocol": "^0.3.0",
46
- "@jxsuite/runtime": "^0.34.2",
47
- "@jxsuite/schema": "^0.35.0",
48
- "@spectrum-web-components/accordion": "^1.12.1",
49
- "@spectrum-web-components/action-bar": "1.12.1",
50
- "@spectrum-web-components/action-button": "^1.12.1",
51
- "@spectrum-web-components/action-group": "^1.12.1",
52
- "@spectrum-web-components/base": "^1.12.1",
53
- "@spectrum-web-components/button": "^1.12.1",
54
- "@spectrum-web-components/checkbox": "^1.12.1",
55
- "@spectrum-web-components/color-area": "^1.12.1",
56
- "@spectrum-web-components/color-handle": "^1.12.1",
57
- "@spectrum-web-components/color-slider": "^1.12.1",
58
- "@spectrum-web-components/combobox": "^1.12.1",
59
- "@spectrum-web-components/dialog": "^1.12.1",
60
- "@spectrum-web-components/divider": "^1.12.1",
61
- "@spectrum-web-components/field-label": "^1.12.1",
62
- "@spectrum-web-components/help-text": "^1.12.1",
63
- "@spectrum-web-components/icon": "^1.12.1",
64
- "@spectrum-web-components/icons-ui": "^1.12.1",
65
- "@spectrum-web-components/icons-workflow": "^1.12.1",
66
- "@spectrum-web-components/menu": "^1.12.1",
67
- "@spectrum-web-components/number-field": "^1.12.1",
68
- "@spectrum-web-components/overlay": "^1.12.1",
42
+ "@jxsuite/collab": "^0.3.0",
43
+ "@jxsuite/create": "^1.0.0",
44
+ "@jxsuite/markup": "^0.2.0",
45
+ "@jxsuite/protocol": "^0.4.0",
46
+ "@jxsuite/runtime": "^1.0.0",
47
+ "@jxsuite/schema": "^1.0.0",
48
+ "@spectrum-web-components/accordion": "^1.12.2",
49
+ "@spectrum-web-components/action-bar": "1.12.2",
50
+ "@spectrum-web-components/action-button": "^1.12.2",
51
+ "@spectrum-web-components/action-group": "^1.12.2",
52
+ "@spectrum-web-components/base": "^1.12.2",
53
+ "@spectrum-web-components/button": "^1.12.2",
54
+ "@spectrum-web-components/checkbox": "^1.12.2",
55
+ "@spectrum-web-components/color-area": "^1.12.2",
56
+ "@spectrum-web-components/color-handle": "^1.12.2",
57
+ "@spectrum-web-components/color-slider": "^1.12.2",
58
+ "@spectrum-web-components/combobox": "^1.12.2",
59
+ "@spectrum-web-components/dialog": "^1.12.2",
60
+ "@spectrum-web-components/divider": "^1.12.2",
61
+ "@spectrum-web-components/field-label": "^1.12.2",
62
+ "@spectrum-web-components/help-text": "^1.12.2",
63
+ "@spectrum-web-components/icon": "^1.12.2",
64
+ "@spectrum-web-components/icons-ui": "^1.12.2",
65
+ "@spectrum-web-components/icons-workflow": "^1.12.2",
66
+ "@spectrum-web-components/menu": "^1.12.2",
67
+ "@spectrum-web-components/number-field": "^1.12.2",
68
+ "@spectrum-web-components/overlay": "^1.12.2",
69
69
  "@spectrum-web-components/overlay-trigger": "^0.1.7",
70
- "@spectrum-web-components/picker": "^1.12.1",
71
- "@spectrum-web-components/picker-button": "^1.12.1",
72
- "@spectrum-web-components/popover": "^1.12.1",
73
- "@spectrum-web-components/progress-circle": "^1.12.1",
74
- "@spectrum-web-components/search": "^1.12.1",
75
- "@spectrum-web-components/swatch": "^1.12.1",
76
- "@spectrum-web-components/switch": "^1.12.1",
77
- "@spectrum-web-components/table": "^1.12.1",
78
- "@spectrum-web-components/tabs": "^1.12.1",
79
- "@spectrum-web-components/textfield": "^1.12.1",
80
- "@spectrum-web-components/theme": "^1.12.1",
81
- "@spectrum-web-components/toast": "^1.12.1",
82
- "@spectrum-web-components/tooltip": "^1.12.1",
83
- "@spectrum-web-components/underlay": "^1.12.1",
70
+ "@spectrum-web-components/picker": "^1.12.2",
71
+ "@spectrum-web-components/picker-button": "^1.12.2",
72
+ "@spectrum-web-components/popover": "^1.12.2",
73
+ "@spectrum-web-components/progress-circle": "^1.12.2",
74
+ "@spectrum-web-components/search": "^1.12.2",
75
+ "@spectrum-web-components/swatch": "^1.12.2",
76
+ "@spectrum-web-components/switch": "^1.12.2",
77
+ "@spectrum-web-components/table": "^1.12.2",
78
+ "@spectrum-web-components/tabs": "^1.12.2",
79
+ "@spectrum-web-components/textfield": "^1.12.2",
80
+ "@spectrum-web-components/theme": "^1.12.2",
81
+ "@spectrum-web-components/toast": "^1.12.2",
82
+ "@spectrum-web-components/tooltip": "^1.12.2",
83
+ "@spectrum-web-components/underlay": "^1.12.2",
84
84
  "@vue/reactivity": "3.5.39",
85
85
  "ajv": "^8.20.0",
86
86
  "ajv-formats": "^3.0.1",
@@ -93,9 +93,9 @@
93
93
  "devDependencies": {
94
94
  "@happy-dom/global-registrator": "^20.10.6",
95
95
  "@types/mdast": "^4.0.4",
96
- "@webref/css": "^8.6.1",
96
+ "@webref/css": "^8.7.0",
97
97
  "@webref/elements": "^2.7.1",
98
- "@webref/idl": "^3.81.2"
98
+ "@webref/idl": "^3.81.3"
99
99
  },
100
100
  "//vue-reactivity": "Exact pin required — must match @jxsuite/runtime to avoid cross-boundary proxy bugs"
101
101
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Account onboarding status — a synchronous render cache over the optional
3
+ * `platform.getAccountStatus` PAL member (cloud GitHub-App installations). Hydrated once at boot
4
+ * (studio.ts) like the project-list cache; the welcome screen reads it synchronously to prompt
5
+ * "install the GitHub App" when the user has no repository access yet.
6
+ */
7
+ import { getPlatform, hasPlatform } from "./platform";
8
+ import type { AccountStatus } from "./types";
9
+
10
+ let cache: AccountStatus | null = null;
11
+
12
+ /** Refresh the cache from the platform; null when unsupported, failing, or unknown. */
13
+ export async function hydrateAccountStatus(): Promise<void> {
14
+ if (!hasPlatform() || typeof getPlatform().getAccountStatus !== "function") {
15
+ cache = null;
16
+ return;
17
+ }
18
+ try {
19
+ cache = (await getPlatform().getAccountStatus?.()) ?? null;
20
+ } catch {
21
+ // Onboarding prompts are progressive enhancement; unknown status never nags.
22
+ cache = null;
23
+ }
24
+ }
25
+
26
+ /** Synchronous snapshot for render paths (never awaits); null = unknown. */
27
+ export function getAccountStatus(): AccountStatus | null {
28
+ return cache;
29
+ }
30
+
31
+ /** True when the account verifiably has no repository access yet and we know where to fix it. */
32
+ export function needsAppInstall(): boolean {
33
+ return cache !== null && cache.installations.length === 0 && Boolean(cache.appInstallUrl);
34
+ }
35
+
36
+ /** Reset seam for tests. */
37
+ export function resetAccountStatus(): void {
38
+ cache = null;
39
+ }
@@ -34,7 +34,8 @@ import {
34
34
  } from "../format/format-host";
35
35
 
36
36
  import type { ComponentEntry } from "../files/components";
37
- import type { ContentTypeDef, JxDocument } from "@jxsuite/schema/types";
37
+ import type { ContentSectionEntry } from "../types";
38
+ import type { JxDocument } from "@jxsuite/schema/types";
38
39
 
39
40
  // ─── Category definitions ────────────────────────────────────────────────────
40
41
 
@@ -184,19 +185,16 @@ async function collectFiles(
184
185
  }
185
186
 
186
187
  /**
187
- * Match a file path against project contentTypes source directories to find its content type name.
188
- * Returns the content type name (capitalized) or null if no match.
188
+ * Match a file path against the project `content` section's source directories to find its content
189
+ * type name. Returns the content type name (capitalized) or null if no match.
189
190
  *
190
191
  * @param {string} filePath
191
192
  * @returns {string | null}
192
193
  */
193
194
  function contentTypeFor(filePath: string) {
194
195
  const config = projectState?.projectConfig;
195
- if (!config?.contentTypes) {
196
- return null;
197
- }
198
- for (const [name, def] of Object.entries(config.contentTypes)) {
199
- const d = def as ContentTypeDef;
196
+ const content = (config?.content ?? {}) as Record<string, ContentSectionEntry>;
197
+ for (const [name, d] of Object.entries(content)) {
200
198
  if (!d.source) {
201
199
  continue;
202
200
  }
@@ -264,7 +262,8 @@ function getEntityTypes() {
264
262
  */
265
263
  function buildFrontmatterYaml(contentTypeName: string) {
266
264
  const config = projectState?.projectConfig;
267
- const col = config?.contentTypes?.[contentTypeName];
265
+ const content = (config?.content ?? {}) as Record<string, ContentSectionEntry>;
266
+ const col = content[contentTypeName];
268
267
  if (!col?.schema?.properties) {
269
268
  return "title: Untitled\n";
270
269
  }
@@ -284,11 +283,8 @@ function buildFrontmatterYaml(contentTypeName: string) {
284
283
  */
285
284
  function getContentTypeTypes() {
286
285
  const config = projectState?.projectConfig;
287
- if (!config?.contentTypes) {
288
- return [];
289
- }
290
- return Object.entries(config.contentTypes).map(([name, def]) => {
291
- const d = def as ContentTypeDef;
286
+ const content = (config?.content ?? {}) as Record<string, ContentSectionEntry>;
287
+ return Object.entries(content).map(([name, d]) => {
292
288
  const dir = d.source ? d.source.replace(/^\.\//, "").replace(/\/$/, "") : name;
293
289
  return {
294
290
  contentTypeName: name,
@@ -1114,7 +1114,7 @@ function handleMessage(state: HostState, msg: IframeToParent): void {
1114
1114
  activeEditHost = null;
1115
1115
  }
1116
1116
  toolbarRefresh?.();
1117
- return;
1117
+ break;
1118
1118
  }
1119
1119
  default: {
1120
1120
  break;
@@ -3,7 +3,7 @@
3
3
  import { html } from "lit-html";
4
4
  import { jsonClone } from "../utils/studio-utils";
5
5
  import { ref } from "lit-html/directives/ref.js";
6
- import { htmlToJx } from "@jxsuite/parser/html-to-jx";
6
+ import { htmlToJx } from "@jxsuite/markup/html-to-jx";
7
7
  import { childIndex, getNodeAtPath, parentElementPath } from "../store";
8
8
  import { activeTab, workspace } from "../workspace/workspace";
9
9
  import {
@@ -16,12 +16,8 @@ import { isRef } from "@jxsuite/schema/guards";
16
16
  import { getNodeAtPath } from "../state";
17
17
 
18
18
  import type { JxPath } from "../state";
19
- import type {
20
- JxMappedArray,
21
- JxMutableNode,
22
- ProjectConfig,
23
- ContentTypeSchema,
24
- } from "@jxsuite/schema/types";
19
+ import type { ContentSectionEntry } from "../types";
20
+ import type { JxMappedArray, JxMutableNode, ProjectConfig } from "@jxsuite/schema/types";
25
21
 
26
22
  /** Cap on how many inferred/declared fields we enumerate (mirrors merge-tags BREADTH_CAP breadth). */
27
23
  const FIELD_CAP = 50;
@@ -73,10 +69,9 @@ function safeFields(names: string[]): string[] {
73
69
  * `index`; appends field tokens resolved from the repeater's binding:
74
70
  *
75
71
  * - **Content collection** — `items` refs a `{ contentType }` state def. Item shape is `{ id, data: {
76
- * …fields }, body }`, so fields come from
77
- * `projectConfig.contentTypes[contentType].schema.properties` and emit as `item.data.<f>` (the
78
- * `.data.` nesting is LOAD-BEARING — runtime templates use `${item.data.title}`), plus `item.id`
79
- * / `item.body`.
72
+ * …fields }, body }`, so fields come from `projectConfig.content[contentType].schema.properties`
73
+ * and emit as `item.data.<f>` (the `.data.` nesting is LOAD-BEARING — runtime templates use
74
+ * `${item.data.title}`), plus `item.id` / `item.body`.
80
75
  * - **State array** — `items` refs a state def that is an array of objects. Fields come from a
81
76
  * declared `items.properties`, else are inferred from a sample `default[0]`. Emitted FLAT
82
77
  * (`item.<f>`, no `.data.`).
@@ -104,9 +99,9 @@ export function resolveRepeaterItemFields(
104
99
  // (a) Content collection — `{ contentType: "<name>" }`. Import-aliased `$prototype` still
105
100
  // Matches because we detect on the `contentType` string, not the prototype name.
106
101
  if (typeof def.contentType === "string") {
107
- const ct = projectConfig?.contentTypes?.[def.contentType];
108
- const schema = ct?.schema as ContentTypeSchema | undefined;
109
- const props = schema?.properties;
102
+ const content = (projectConfig?.content ?? {}) as Record<string, ContentSectionEntry>;
103
+ const ct = content[def.contentType];
104
+ const props = ct?.schema?.properties;
110
105
  const out = [...base, "item.id", "item.body"];
111
106
  if (props && typeof props === "object") {
112
107
  for (const f of safeFields(Object.keys(props))) {
@@ -34,6 +34,7 @@ import {
34
34
  formatForPath,
35
35
  loadFormats,
36
36
  noFormatError,
37
+ refreshExtensionUi,
37
38
  refreshFormats,
38
39
  } from "../format/format-host";
39
40
  import { view } from "../view";
@@ -83,6 +84,7 @@ export async function loadProject() {
83
84
 
84
85
  refreshFormats();
85
86
  void loadFormats();
87
+ refreshExtensionUi(platform);
86
88
 
87
89
  setProjectState({
88
90
  dirs: new Map(),
@@ -145,6 +147,7 @@ export async function openProject({
145
147
 
146
148
  refreshFormats();
147
149
  void loadFormats();
150
+ refreshExtensionUi(platform);
148
151
 
149
152
  setProjectState({
150
153
  .../** @type {ProjectState} */ projectState,
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { getPlatform } from "../platform";
11
+ import type { ExtensionsInfo } from "../types";
11
12
  import type { JxMutableNode } from "@jxsuite/schema/types";
12
13
 
13
14
  export interface StudioFormatHints {
@@ -68,10 +69,12 @@ export function loadFormats(): Promise<StudioFormat[]> {
68
69
  return _loaded;
69
70
  }
70
71
 
71
- /** Invalidate the cached registry (call on project switch). */
72
+ /** Invalidate the cached registry and extensions payload (call on project switch). */
72
73
  export function refreshFormats() {
73
74
  _loaded = null;
74
75
  _formats = [];
76
+ _extensionsLoaded = null;
77
+ _extensions = [];
75
78
  }
76
79
 
77
80
  /** Seed the registry directly (tests and hosts that preload format metadata). */
@@ -80,6 +83,65 @@ export function setFormats(formats: StudioFormat[]) {
80
83
  _loaded = Promise.resolve(formats);
81
84
  }
82
85
 
86
+ // ─── Extensions payload (specs/extensions.md §9/§9.1) ────────────────────────
87
+
88
+ let _extensions: ExtensionsInfo[] = [];
89
+ let _extensionsLoaded: Promise<ExtensionsInfo[]> | null = null;
90
+
91
+ /**
92
+ * Load (and cache) the project's enabled extensions with their project-section contributions.
93
+ * Backed by the platform's optional `listExtensions` member; platforms without it (or failures)
94
+ * degrade to an empty list, hiding descriptor-contributed settings sections.
95
+ */
96
+ export function loadExtensions(): Promise<ExtensionsInfo[]> {
97
+ if (!_extensionsLoaded) {
98
+ _extensionsLoaded = (async () => {
99
+ try {
100
+ const platform = getPlatform() as {
101
+ listExtensions?: () => Promise<ExtensionsInfo[]>;
102
+ };
103
+ _extensions = (await platform.listExtensions?.()) ?? [];
104
+ } catch {
105
+ _extensions = [];
106
+ }
107
+ return _extensions;
108
+ })();
109
+ }
110
+ return _extensionsLoaded;
111
+ }
112
+
113
+ /** The last-loaded extensions payload (synchronous access for render paths). */
114
+ export function getExtensions(): ExtensionsInfo[] {
115
+ return _extensions;
116
+ }
117
+
118
+ /** Seed the extensions payload directly (tests and hosts that preload it). */
119
+ export function setExtensions(extensions: ExtensionsInfo[]) {
120
+ _extensions = extensions;
121
+ _extensionsLoaded = Promise.resolve(extensions);
122
+ }
123
+
124
+ /**
125
+ * Fire-and-forget refresh of the extension-facing editor surface after project (re)activation or an
126
+ * `extensions` change: Monaco's per-project schemas and the descriptor-contributed settings
127
+ * sections. Both modules load lazily — monaco is heavy and the settings registry pulls DOM
128
+ * templates — and both degrade silently (bundled core schemas, built-in sections only).
129
+ *
130
+ * @param {object} platform - The studio platform (only `fetchProjectSchemas` is consulted here)
131
+ */
132
+ export function refreshExtensionUi(platform: {
133
+ fetchProjectSchemas?: () => Promise<{ project?: unknown; document?: unknown }>;
134
+ }): void {
135
+ void import("../services/monaco-setup")
136
+ .then(({ refreshProjectSchemas }) => refreshProjectSchemas(platform))
137
+ .catch(() => false);
138
+ void import("../settings/extension-sections")
139
+ .then(({ syncExtensionSettingsSections }) => syncExtensionSettingsSections())
140
+ .catch(() => {
141
+ // Contributed sections also refresh on the next settings-modal open.
142
+ });
143
+ }
144
+
83
145
  /** The last-loaded registry (synchronous access for render paths). */
84
146
  export function getFormats(): StudioFormat[] {
85
147
  return _formats;
@@ -0,0 +1,183 @@
1
+ /// <reference lib="dom" />
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.
9
+ */
10
+
11
+ import { html, nothing } from "lit-html";
12
+ import { errorMessage } from "@jxsuite/schema/parse";
13
+ import { getPlatform } from "../platform";
14
+ import { openModal } from "../ui/layers";
15
+ import type { RepoInfo } from "../types";
16
+
17
+ let _handle: ReturnType<typeof openModal> | null = null;
18
+ let _repos: RepoInfo[] | null = null;
19
+ let _filter = "";
20
+ let _error = "";
21
+ /** FullName of the repo currently importing ("" = idle). */
22
+ let _importing = "";
23
+ let _resolve: ((result: { root: string } | null) => void) | null = null;
24
+
25
+ /** True when the active platform can browse + adopt existing repositories. */
26
+ export function platformSupportsAddRepo(): boolean {
27
+ const platform = getPlatform();
28
+ return typeof platform.listRepos === "function" && typeof platform.importProject === "function";
29
+ }
30
+
31
+ /** Open the picker. Resolves with the imported project's catalogue root key, or null when cancelled. */
32
+ export function openAddRepoModal(): Promise<{ root: string } | null> {
33
+ if (_handle) {
34
+ return Promise.resolve(null);
35
+ }
36
+ _repos = null;
37
+ _filter = "";
38
+ _error = "";
39
+ _importing = "";
40
+
41
+ void getPlatform()
42
+ .listRepos?.()
43
+ .then((repos) => {
44
+ _repos = repos;
45
+ })
46
+ .catch((error: unknown) => {
47
+ _repos = [];
48
+ _error = errorMessage(error);
49
+ })
50
+ .finally(() => {
51
+ renderModal();
52
+ });
53
+
54
+ return new Promise((resolve) => {
55
+ _resolve = resolve;
56
+ renderModal();
57
+ });
58
+ }
59
+
60
+ export function closeAddRepoModal() {
61
+ if (!_handle || _importing) {
62
+ return;
63
+ }
64
+ _handle.close();
65
+ _handle = null;
66
+ if (_resolve) {
67
+ _resolve(null);
68
+ _resolve = null;
69
+ }
70
+ }
71
+
72
+ function finish(result: { root: string }) {
73
+ _importing = "";
74
+ if (_handle) {
75
+ _handle.close();
76
+ _handle = null;
77
+ }
78
+ if (_resolve) {
79
+ _resolve(result);
80
+ _resolve = null;
81
+ }
82
+ }
83
+
84
+ async function chooseRepo(repo: RepoInfo) {
85
+ if (_importing) {
86
+ return;
87
+ }
88
+ _importing = repo.fullName;
89
+ _error = "";
90
+ renderModal();
91
+ try {
92
+ const imported = await getPlatform().importProject?.({ name: repo.name, owner: repo.owner });
93
+ if (imported) {
94
+ finish(imported);
95
+ return;
96
+ }
97
+ _error = "This platform cannot import repositories.";
98
+ } catch (error) {
99
+ _error = errorMessage(error);
100
+ }
101
+ _importing = "";
102
+ renderModal();
103
+ }
104
+
105
+ function visibleRepos(): RepoInfo[] {
106
+ const query = _filter.trim().toLowerCase();
107
+ const repos = _repos ?? [];
108
+ return query ? repos.filter((r) => r.fullName.toLowerCase().includes(query)) : repos;
109
+ }
110
+
111
+ function repoRowTpl(repo: RepoInfo) {
112
+ return html`
113
+ <button
114
+ class="add-repo-row"
115
+ ?disabled=${Boolean(_importing)}
116
+ title=${repo.fullName}
117
+ @click=${() => void chooseRepo(repo)}
118
+ >
119
+ <span class="add-repo-name">${repo.fullName}</span>
120
+ <span class="add-repo-meta">
121
+ ${repo.isJxProject ? html`<span class="add-repo-badge">Jx</span>` : nothing}
122
+ ${repo.private ? html`<span class="add-repo-badge">private</span>` : nothing}
123
+ <span>${repo.defaultBranch} · ${repo.permission}</span>
124
+ </span>
125
+ ${_importing === repo.fullName
126
+ ? html`<span class="add-repo-busy">Importing…</span>`
127
+ : nothing}
128
+ </button>
129
+ `;
130
+ }
131
+
132
+ function bodyTpl() {
133
+ if (_repos === null) {
134
+ return html`<div class="add-repo-empty">Loading repositories…</div>`;
135
+ }
136
+ const repos = visibleRepos();
137
+ 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>`;
143
+ }
144
+ return html`<div class="add-repo-list">${repos.map((repo) => repoRowTpl(repo))}</div>`;
145
+ }
146
+
147
+ function renderModal() {
148
+ const tpl = html`
149
+ <sp-underlay open @close=${closeAddRepoModal}></sp-underlay>
150
+ <div
151
+ class="new-project-modal add-repo-modal"
152
+ @keydown=${(e: KeyboardEvent) => {
153
+ if (e.key === "Escape") {
154
+ closeAddRepoModal();
155
+ }
156
+ }}
157
+ >
158
+ <div class="new-project-modal-header">
159
+ <h2 class="new-project-modal-title">Add existing repository</h2>
160
+ <sp-action-button quiet size="s" @click=${closeAddRepoModal} title="Close">
161
+ <sp-icon-close slot="icon"></sp-icon-close>
162
+ </sp-action-button>
163
+ </div>
164
+ <div class="new-project-modal-body">
165
+ <sp-textfield
166
+ class="add-repo-filter"
167
+ placeholder="Filter repositories…"
168
+ value=${_filter}
169
+ @input=${(e: Event) => {
170
+ _filter = (e.target as HTMLInputElement).value;
171
+ renderModal();
172
+ }}
173
+ ></sp-textfield>
174
+ ${bodyTpl()} ${_error ? html`<div class="new-project-error">${_error}</div>` : nothing}
175
+ </div>
176
+ </div>
177
+ `;
178
+ if (_handle) {
179
+ _handle.update(tpl);
180
+ } else {
181
+ _handle = openModal(tpl);
182
+ }
183
+ }
@@ -13,6 +13,7 @@ import { html } from "lit-html";
13
13
  import { errorMessage } from "@jxsuite/schema/parse";
14
14
  import { openModal } from "../ui/layers";
15
15
  import { getPlatform } from "../platform";
16
+ import { installUrlOf } from "../platform-errors";
16
17
  import { hasOpenAiKey } from "../services/ai-settings";
17
18
  import { setPendingAgentPrompt } from "../services/agent-seed";
18
19
  import { createAiCredentialsForm } from "../ui/ai-credentials-form";
@@ -57,6 +58,14 @@ let _paramsSeededFor = "";
57
58
 
58
59
  let _error = "";
59
60
 
61
+ /** GitHub-App install link carried by a structured needs_installation_access failure. */
62
+ let _errorInstallUrl = "";
63
+
64
+ function captureError(error: unknown) {
65
+ _error = errorMessage(error);
66
+ _errorInstallUrl = installUrlOf(error) ?? "";
67
+ }
68
+
60
69
  let _creating = false;
61
70
 
62
71
  /** Starter templates offered in the picker (empty until loaded / on platforms without starters). */
@@ -111,6 +120,7 @@ export function openNewProjectModal(): Promise<{
111
120
  _agentPrompt = "";
112
121
  _paramsSeededFor = "";
113
122
  _error = "";
123
+ _errorInstallUrl = "";
114
124
  _creating = false;
115
125
  _starters = [];
116
126
  _dirDerived = true;
@@ -320,7 +330,7 @@ function renderModal() {
320
330
  finish(result);
321
331
  } catch (error) {
322
332
  _creating = false;
323
- _error = errorMessage(error);
333
+ captureError(error);
324
334
  renderModal();
325
335
  }
326
336
  };
@@ -351,7 +361,7 @@ function renderModal() {
351
361
  finish(result);
352
362
  } catch (error) {
353
363
  _creating = false;
354
- _error = errorMessage(error);
364
+ captureError(error);
355
365
  renderModal();
356
366
  }
357
367
  };
@@ -593,7 +603,16 @@ function renderModal() {
593
603
  : ""}
594
604
  <div class="new-project-modal-body">
595
605
  ${bodyTpl()}
596
- ${_tab !== "import" && _error ? html`<div class="new-project-error">${_error}</div>` : ""}
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
+ : ""}
597
616
  </div>
598
617
  <div class="new-project-modal-footer">${footerTpl()}</div>
599
618
  </div>