@jxsuite/studio 0.37.1 → 1.1.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 (113) hide show
  1. package/dist/iframe-entry.js +622 -49
  2. package/dist/iframe-entry.js.map +15 -12
  3. package/dist/studio.css +1333 -0
  4. package/dist/studio.js +103709 -81001
  5. package/dist/studio.js.map +201 -157
  6. package/package.json +47 -45
  7. package/src/account-status.ts +39 -0
  8. package/src/browse/browse.ts +10 -14
  9. package/src/canvas/canvas-live-render.ts +21 -2
  10. package/src/canvas/canvas-render.ts +35 -16
  11. package/src/canvas/canvas-utils.ts +118 -72
  12. package/src/canvas/iframe-entry.ts +20 -0
  13. package/src/canvas/iframe-eval.ts +155 -0
  14. package/src/canvas/iframe-host.ts +132 -6
  15. package/src/canvas/iframe-inline-edit.ts +107 -1
  16. package/src/canvas/iframe-protocol.ts +43 -3
  17. package/src/canvas/iframe-render.ts +18 -0
  18. package/src/collab/collab-session.ts +23 -8
  19. package/src/collab/collab-state.ts +6 -3
  20. package/src/component-props.ts +104 -0
  21. package/src/editor/context-menu.ts +1 -1
  22. package/src/editor/inline-edit-apply.ts +36 -1
  23. package/src/editor/inline-edit.ts +58 -1
  24. package/src/editor/repeater-scope.ts +8 -13
  25. package/src/editor/shortcuts.ts +41 -12
  26. package/src/files/file-ops.ts +14 -0
  27. package/src/files/files.ts +56 -1
  28. package/src/format/format-host.ts +63 -1
  29. package/src/grid/cell-editors.ts +288 -0
  30. package/src/grid/cell-popovers.ts +108 -0
  31. package/src/grid/csv-codec.ts +122 -0
  32. package/src/grid/edit-buffer.ts +490 -0
  33. package/src/grid/grid-controller.ts +417 -0
  34. package/src/grid/grid-layout.ts +83 -0
  35. package/src/grid/grid-open.ts +167 -0
  36. package/src/grid/grid-panel.ts +302 -0
  37. package/src/grid/grid-source.ts +210 -0
  38. package/src/grid/grid-view.ts +367 -0
  39. package/src/grid/schema-columns.ts +261 -0
  40. package/src/grid/sources/connector-source.ts +217 -0
  41. package/src/grid/sources/content-source.ts +542 -0
  42. package/src/grid/sources/csv-file-source.ts +247 -0
  43. package/src/grid/tabulator-tables.d.ts +120 -0
  44. package/src/new-project/add-repo-modal.ts +183 -0
  45. package/src/new-project/new-project-modal.ts +22 -3
  46. package/src/page-params.ts +34 -8
  47. package/src/panels/ai-chat/chat-markdown.ts +2 -2
  48. package/src/panels/ai-panel.ts +61 -4
  49. package/src/panels/block-action-bar.ts +8 -3
  50. package/src/panels/chat-panel.ts +98 -0
  51. package/src/panels/data-grid.ts +241 -0
  52. package/src/panels/editors.ts +43 -17
  53. package/src/panels/events-panel.ts +137 -44
  54. package/src/panels/formula-workspace.ts +379 -0
  55. package/src/panels/frontmatter-fields.ts +231 -0
  56. package/src/panels/frontmatter-panel.ts +132 -0
  57. package/src/panels/git-panel.ts +1 -1
  58. package/src/panels/head-panel.ts +11 -175
  59. package/src/panels/properties-panel.ts +154 -144
  60. package/src/panels/right-panel.ts +9 -47
  61. package/src/panels/signals-panel.ts +217 -460
  62. package/src/panels/statement-editor.ts +710 -0
  63. package/src/panels/statusbar.ts +1 -1
  64. package/src/panels/style-panel.ts +25 -1
  65. package/src/panels/stylebook-panel.ts +0 -2
  66. package/src/panels/tab-bar.ts +175 -6
  67. package/src/panels/tab-strip.ts +62 -6
  68. package/src/panels/toolbar.ts +37 -3
  69. package/src/panels/welcome-screen.ts +50 -0
  70. package/src/platform-errors.ts +30 -0
  71. package/src/platforms/cloud.ts +172 -89
  72. package/src/platforms/devserver.ts +172 -0
  73. package/src/services/ai-project-tools.ts +541 -0
  74. package/src/services/ai-session-store.ts +28 -0
  75. package/src/services/ai-system-prompt.ts +194 -24
  76. package/src/services/ai-tools.ts +88 -21
  77. package/src/services/automation.ts +16 -0
  78. package/src/services/context-resolver.ts +73 -0
  79. package/src/services/data-service.ts +155 -0
  80. package/src/services/document-assistant.ts +81 -11
  81. package/src/services/gated-registry.ts +72 -0
  82. package/src/services/live-preview.ts +0 -0
  83. package/src/services/monaco-setup.ts +75 -26
  84. package/src/services/preview-eval.ts +68 -0
  85. package/src/services/project-adoption.ts +31 -0
  86. package/src/settings/contributed-section.ts +406 -0
  87. package/src/settings/extension-sections.ts +145 -0
  88. package/src/settings/schema-field-ui.ts +4 -2
  89. package/src/settings/settings-modal.ts +101 -42
  90. package/src/site-context.ts +12 -1
  91. package/src/store.ts +4 -0
  92. package/src/studio.ts +107 -52
  93. package/src/tabs/patch-ops.ts +25 -0
  94. package/src/tabs/tab.ts +39 -1
  95. package/src/tabs/transact.ts +61 -14
  96. package/src/types.ts +132 -1
  97. package/src/ui/dynamic-slot.ts +272 -0
  98. package/src/ui/expression-editor.ts +423 -125
  99. package/src/ui/field-row.ts +5 -0
  100. package/src/ui/form-controls.ts +322 -0
  101. package/src/ui/formula-catalog.ts +557 -0
  102. package/src/ui/formula-chips.ts +216 -0
  103. package/src/ui/formula-palette.ts +211 -0
  104. package/src/ui/layers.ts +40 -0
  105. package/src/ui/media-picker.ts +1 -1
  106. package/src/ui/panel-resize.ts +15 -1
  107. package/src/ui/progress-modal.ts +2 -2
  108. package/src/ui/schema-form.ts +524 -0
  109. package/src/utils/preview-format.ts +26 -0
  110. package/src/utils/studio-utils.ts +4 -3
  111. package/src/view.ts +4 -4
  112. package/src/workspace/workspace.ts +14 -0
  113. package/src/settings/content-types-editor.ts +0 -599
@@ -0,0 +1,247 @@
1
+ /**
2
+ * CSV file grid source — one `.csv` project file edited as a grid.
3
+ *
4
+ * The file loads once into a positional string matrix (csv-codec). Columns are typed by the
5
+ * matching content-type schema when the CSV backs a collection (same resolver as the head panel),
6
+ * otherwise inferred by sampling values. Commit is atomic whole-file: pending edits are applied to
7
+ * the matrix, the document is re-serialized, and a save-time staleness check (file text vs. the
8
+ * load-time text) aborts the entire commit rather than clobber an external change. Only edited
9
+ * cells are canonicalized — untouched cells keep their raw file text.
10
+ */
11
+ import { getPlatform } from "../../platform";
12
+ import { projectState } from "../../store";
13
+ import { findContentTypeSchema } from "../../utils/studio-utils";
14
+ import { isRecentLocal, markLocalMutation } from "../../files/fs-events";
15
+ import { parseCsv, serializeCsv } from "../csv-codec";
16
+ import {
17
+ cellToText,
18
+ coerceCellInput,
19
+ columnsFromSchema,
20
+ inferColumnsFromRows,
21
+ } from "../schema-columns";
22
+ import type { CsvDocument } from "../csv-codec";
23
+ import type {
24
+ CommitResult,
25
+ GridCellValue,
26
+ GridColumn,
27
+ GridEditBatch,
28
+ GridRow,
29
+ GridRowsResult,
30
+ GridSource,
31
+ } from "../grid-source";
32
+
33
+ /** Candidate identity columns, in the parser extension's fallback order. */
34
+ const ID_FIELDS = ["id", "sku", "slug", "Slug"];
35
+
36
+ interface CsvModel {
37
+ doc: CsvDocument;
38
+ /** Unique field name per column position (duplicate/empty headers get positional names). */
39
+ fields: string[];
40
+ columns: GridColumn[];
41
+ /** Map of rowKey → matrix row index. */
42
+ keyToIndex: Map<string, number>;
43
+ /** Matrix row index → rowKey. */
44
+ keys: string[];
45
+ /** File text at load time — the whole-file staleness fingerprint. */
46
+ loadText: string;
47
+ }
48
+
49
+ /** Unique grid field names for CSV headers (duplicates/empties fall back to positional names). */
50
+ export function fieldNamesForHeaders(headers: string[]): string[] {
51
+ const used = new Set<string>();
52
+ return headers.map((header, i) => {
53
+ let name = header.trim() === "" ? `column${i + 1}` : header;
54
+ if (used.has(name)) {
55
+ name = `${name}__${i + 1}`;
56
+ }
57
+ used.add(name);
58
+ return name;
59
+ });
60
+ }
61
+
62
+ /** Pick the identity column: first id-chain field whose values are all unique and non-empty. */
63
+ function pickIdField(fields: string[], doc: CsvDocument): string | null {
64
+ for (const candidate of ID_FIELDS) {
65
+ const idx = fields.indexOf(candidate);
66
+ if (idx === -1) {
67
+ continue;
68
+ }
69
+ const values = doc.rows.map((row) => row[idx] ?? "");
70
+ if (values.every((v) => v !== "") && new Set(values).size === values.length) {
71
+ return candidate;
72
+ }
73
+ }
74
+ return null;
75
+ }
76
+
77
+ function buildModel(text: string, path: string): CsvModel {
78
+ const doc = parseCsv(text);
79
+ const fields = fieldNamesForHeaders(doc.headers);
80
+ const idField = pickIdField(fields, doc);
81
+ const idIndex = idField ? fields.indexOf(idField) : -1;
82
+ const keys = doc.rows.map((row, i) => (idIndex === -1 ? String(i) : row[idIndex]!));
83
+ const keyToIndex = new Map(keys.map((key, i) => [key, i]));
84
+
85
+ // Typed columns when this CSV is a collection source; sniffed kinds otherwise.
86
+ const contentType = findContentTypeSchema(path, projectState?.projectConfig);
87
+ const schemaProps = contentType?.schema?.properties ?? {};
88
+ const sampleRows = doc.rows.slice(0, 50).map((row) => {
89
+ const cells: Record<string, GridCellValue> = {};
90
+ for (const [i, field] of fields.entries()) {
91
+ cells[field] = row[i] ?? "";
92
+ }
93
+ return cells;
94
+ });
95
+ const inferred = new Map(inferColumnsFromRows(sampleRows).map((c) => [c.field, c]));
96
+ const typed = new Map(
97
+ columnsFromSchema(contentType?.schema, { idField: idField ?? undefined }).map((c) => [
98
+ c.field,
99
+ c,
100
+ ]),
101
+ );
102
+
103
+ const columns = fields.map((field, i): GridColumn => {
104
+ const fromSchema = field in schemaProps ? typed.get(field) : undefined;
105
+ const column = fromSchema ??
106
+ inferred.get(field) ?? {
107
+ editable: true,
108
+ field,
109
+ kind: "string" as const,
110
+ title: doc.headers[i] || field,
111
+ };
112
+ return { ...column, pk: field === idField, title: doc.headers[i]?.trim() || column.title };
113
+ });
114
+
115
+ return { columns, doc, fields, keys, keyToIndex, loadText: text };
116
+ }
117
+
118
+ /** Typed cell values for one matrix row. */
119
+ function rowCells(model: CsvModel, rowIndex: number): Record<string, GridCellValue> {
120
+ const cells: Record<string, GridCellValue> = {};
121
+ const row = model.doc.rows[rowIndex] ?? [];
122
+ for (const [i, field] of model.fields.entries()) {
123
+ const column = model.columns[i]!;
124
+ const raw = row[i] ?? "";
125
+ cells[field] =
126
+ column.kind === "string" || column.kind === "text" || column.kind === "readonly"
127
+ ? raw
128
+ : coerceCellInput(raw, column);
129
+ }
130
+ return cells;
131
+ }
132
+
133
+ /** Apply a batch to a copy of the matrix (cells, then inserts, then deletes). */
134
+ function applyBatch(model: CsvModel, batch: GridEditBatch): CsvDocument {
135
+ const rows = model.doc.rows.map((row) => [...row]);
136
+ const colFor = (field: string) => model.fields.indexOf(field);
137
+
138
+ for (const cell of batch.cells) {
139
+ const rowIndex = model.keyToIndex.get(cell.rowKey);
140
+ const colIndex = colFor(cell.field);
141
+ if (rowIndex === undefined || colIndex === -1) {
142
+ continue;
143
+ }
144
+ rows[rowIndex]![colIndex] = cellToText(cell.value);
145
+ }
146
+ for (const insert of batch.inserts) {
147
+ const row = model.fields.map((field) => cellToText(insert.cells[field] ?? null));
148
+ rows.push(row);
149
+ }
150
+ const dropIndexes = batch.deletes
151
+ .map((del) => model.keyToIndex.get(del.rowKey))
152
+ .filter((i): i is number => i !== undefined)
153
+ .toSorted((a, b) => b - a);
154
+ for (const index of dropIndexes) {
155
+ rows.splice(index, 1);
156
+ }
157
+ return { ...model.doc, rows };
158
+ }
159
+
160
+ /** All batch items resolved with one shared outcome (whole-file commits are atomic). */
161
+ function uniformResult(
162
+ batch: GridEditBatch,
163
+ outcome: { ok: boolean; error?: string; stale?: boolean },
164
+ ): CommitResult {
165
+ return {
166
+ cells: batch.cells.map((c) => ({ field: c.field, rowKey: c.rowKey, ...outcome })),
167
+ deletes: batch.deletes.map((d) => ({ rowKey: d.rowKey, ...outcome })),
168
+ inserts: batch.inserts.map((i) => ({
169
+ error: outcome.error,
170
+ ok: outcome.ok,
171
+ tempKey: i.tempKey,
172
+ })),
173
+ };
174
+ }
175
+
176
+ /** Create the grid source for one CSV file. The tab id is the file path. */
177
+ export function createCsvFileSource(path: string): GridSource {
178
+ let loadPromise: Promise<CsvModel> | null = null;
179
+
180
+ const load = (force = false): Promise<CsvModel> => {
181
+ if (!force && loadPromise) {
182
+ return loadPromise;
183
+ }
184
+ loadPromise = getPlatform()
185
+ .readFile(path)
186
+ .then((text) => buildModel(text, path));
187
+ return loadPromise;
188
+ };
189
+
190
+ return {
191
+ backingPaths: () => new Map([[path, "*"]]),
192
+ capabilities: { delete: true, insert: true, remotePaging: false, remoteSort: false },
193
+
194
+ async columns(): Promise<GridColumn[]> {
195
+ const current = await load();
196
+ return current.columns;
197
+ },
198
+
199
+ async commit(batch: GridEditBatch): Promise<CommitResult> {
200
+ const current = await load();
201
+ const platform = getPlatform();
202
+
203
+ // Whole-file staleness: abort if the file changed under us (and it wasn't our own write).
204
+ let onDisk: string | null = null;
205
+ try {
206
+ onDisk = await platform.readFile(path);
207
+ } catch {
208
+ onDisk = null; // Deleted underneath — treat as stale.
209
+ }
210
+ if (onDisk !== current.loadText && !isRecentLocal(path)) {
211
+ return uniformResult(batch, {
212
+ error: "File changed on disk — refresh to reload",
213
+ ok: false,
214
+ stale: true,
215
+ });
216
+ }
217
+
218
+ const nextDoc = applyBatch(current, batch);
219
+ const text = serializeCsv(nextDoc);
220
+ markLocalMutation(path);
221
+ await platform.writeFile(path, text);
222
+ loadPromise = Promise.resolve(buildModel(text, path));
223
+ return uniformResult(batch, { ok: true });
224
+ },
225
+
226
+ id: path,
227
+ label: path.split("/").pop() ?? path,
228
+
229
+ async refresh(): Promise<void> {
230
+ await load(true);
231
+ },
232
+
233
+ async rows(): Promise<GridRowsResult> {
234
+ const current = await load();
235
+ const rows: GridRow[] = current.doc.rows.map((_, i) => ({
236
+ cells: rowCells(current, i),
237
+ key: current.keys[i]!,
238
+ }));
239
+ return { rows, total: rows.length };
240
+ },
241
+
242
+ async serializeForSource(batch: GridEditBatch): Promise<string> {
243
+ const current = await load();
244
+ return serializeCsv(applyBatch(current, batch));
245
+ },
246
+ };
247
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Minimal ambient types for tabulator-tables 6.x — the package ships no declarations. Only the
3
+ * surface grid-view.ts touches is typed; extend here as usage grows.
4
+ */
5
+ declare module "tabulator-tables" {
6
+ export interface CellComponent {
7
+ getValue: () => unknown;
8
+ getField: () => string;
9
+ getElement: () => HTMLElement;
10
+ getRow: () => RowComponent;
11
+ setValue: (value: unknown, mutate?: boolean) => void;
12
+ }
13
+
14
+ export interface RowComponent {
15
+ getData: () => Record<string, unknown>;
16
+ getElement: () => HTMLElement;
17
+ getCells: () => CellComponent[];
18
+ reformat: () => void;
19
+ }
20
+
21
+ export interface RangeComponent {
22
+ getRows: () => RowComponent[];
23
+ getCells: () => CellComponent[][];
24
+ }
25
+
26
+ export interface ColumnComponent {
27
+ getField: () => string;
28
+ getWidth: () => number;
29
+ }
30
+
31
+ export type CellEditor = (
32
+ cell: CellComponent,
33
+ onRendered: (fn: () => void) => void,
34
+ success: (value: unknown) => void,
35
+ cancel: () => void,
36
+ ) => HTMLElement | false;
37
+
38
+ export type CellFormatter = (
39
+ cell: CellComponent,
40
+ formatterParams: Record<string, unknown>,
41
+ onRendered: (fn: () => void) => void,
42
+ ) => HTMLElement | string;
43
+
44
+ export interface ColumnDefinition {
45
+ title: string;
46
+ field?: string | undefined;
47
+ width?: number | undefined;
48
+ frozen?: boolean | undefined;
49
+ editor?: CellEditor | undefined;
50
+ editable?: boolean | ((cell: CellComponent) => boolean) | undefined;
51
+ formatter?: CellFormatter | undefined;
52
+ sorter?: string | ((a: unknown, b: unknown) => number) | undefined;
53
+ headerSort?: boolean | undefined;
54
+ headerFilter?: string | undefined;
55
+ cssClass?: string | undefined;
56
+ resizable?: boolean | string | undefined;
57
+ headerSortTristate?: boolean | undefined;
58
+ }
59
+
60
+ export interface Options {
61
+ data?: Record<string, unknown>[];
62
+ columns?: ColumnDefinition[];
63
+ index?: string;
64
+ layout?: string;
65
+ height?: string | number;
66
+ renderVertical?: string;
67
+ renderHorizontal?: string;
68
+ history?: boolean;
69
+ reactiveData?: boolean;
70
+ editTriggerEvent?: string;
71
+ headerSortClickElement?: string;
72
+ selectableRange?: number | boolean;
73
+ selectableRangeColumns?: boolean;
74
+ selectableRangeRows?: boolean;
75
+ selectableRangeClearCells?: boolean;
76
+ selectableRangeClearCellsValue?: unknown;
77
+ clipboard?: boolean;
78
+ clipboardCopyStyled?: boolean;
79
+ clipboardCopyConfig?: Record<string, unknown>;
80
+ clipboardCopyRowRange?: string;
81
+ clipboardPasteParser?: string;
82
+ clipboardPasteAction?: string;
83
+ movableColumns?: boolean;
84
+ columnDefaults?: Partial<ColumnDefinition>;
85
+ rowFormatter?: (row: RowComponent) => void;
86
+ placeholder?: string;
87
+ }
88
+
89
+ export class Tabulator {
90
+ constructor(host: HTMLElement, options: Options);
91
+ static registerModule(modules: unknown[]): void;
92
+ on(event: string, callback: (...args: never[]) => void): void;
93
+ replaceData(data: Record<string, unknown>[]): Promise<void>;
94
+ getRows(activeOnly?: string): RowComponent[];
95
+ getRanges(): RangeComponent[];
96
+ setFilter(filter: (data: Record<string, unknown>) => boolean): void;
97
+ clearFilter(includeHeaderFilters?: boolean): void;
98
+ redraw(force?: boolean): void;
99
+ destroy(): void;
100
+ }
101
+
102
+ export const EditModule: unknown;
103
+ export const FormatModule: unknown;
104
+ export const SortModule: unknown;
105
+ export const FilterModule: unknown;
106
+ export const ResizeColumnsModule: unknown;
107
+ export const MoveColumnsModule: unknown;
108
+ export const FrozenColumnsModule: unknown;
109
+ export const SelectRangeModule: unknown;
110
+ export const ClipboardModule: unknown;
111
+ export const ExportModule: unknown;
112
+ export const KeybindingsModule: unknown;
113
+ export const InteractionModule: unknown;
114
+ export const TooltipModule: unknown;
115
+ }
116
+
117
+ declare module "tabulator-tables/dist/css/tabulator.min.css" {
118
+ const css: unknown;
119
+ export default css;
120
+ }
@@ -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>
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { getPlatform } from "./platform";
14
+ import { loadExtensions } from "./format/format-host";
14
15
  import type { JxMutableNode, JxPathsDef } from "@jxsuite/schema/types";
15
16
 
16
17
  /** Param name → candidate values, in `$paths` declaration order. */
@@ -135,10 +136,15 @@ async function resolveParamValues(pathsDef: JxPathsDef): Promise<ParamValues> {
135
136
  return out;
136
137
  }
137
138
 
138
- // Content type-based: { contentType: "blog", param: "slug", field: "id" } — resolved through the
139
- // Backend's ContentCollection pipeline (the same one the canvas's ContentEntry uses), so every
140
- // Offered value is guaranteed to resolve in preview.
141
- if ("contentType" in pathsDef && pathsDef.contentType) {
139
+ // Content type-based: { contentType: "blog", param: "slug", field: "id" } — an open extension
140
+ // Discriminator member on JxPathsDef, so narrow to string. Resolved through the backend's
141
+ // ContentCollection pipeline (the same one the canvas's ContentEntry uses), so every offered
142
+ // Value is guaranteed to resolve in preview.
143
+ if (
144
+ "contentType" in pathsDef &&
145
+ typeof pathsDef.contentType === "string" &&
146
+ pathsDef.contentType
147
+ ) {
142
148
  const param = pathsDef.param ?? "slug";
143
149
  const field = pathsDef.field ?? "id";
144
150
  const entries = (await resolveContentCollection(pathsDef.contentType)) as {
@@ -165,8 +171,9 @@ async function resolveParamValues(pathsDef: JxPathsDef): Promise<ParamValues> {
165
171
  return out;
166
172
  }
167
173
 
168
- // Data file ref: { "$ref": "./data/products.json", param: "id", field: "sku" }
169
- if ("$ref" in pathsDef && pathsDef.$ref) {
174
+ // Data file ref: { "$ref": "./data/products.json", param: "id", field: "sku" } — `$ref` also
175
+ // Reaches here as an open discriminator key, so narrow to a non-empty string.
176
+ if ("$ref" in pathsDef && typeof pathsDef.$ref === "string" && pathsDef.$ref) {
170
177
  const param = pathsDef.param ?? "id";
171
178
  const field = pathsDef.field ?? "id";
172
179
  const content = await getPlatform().readFile(pathsDef.$ref.replace(/^\.\//, ""));
@@ -184,6 +191,25 @@ async function resolveParamValues(pathsDef: JxPathsDef): Promise<ParamValues> {
184
191
  return {};
185
192
  }
186
193
 
194
+ /**
195
+ * The `$src` of the ContentCollection class from the enabled extensions payload — whichever
196
+ * extension's manifest declares a class named `ContentCollection` (its backend-resolved descriptor
197
+ * path feeds `/__jx_resolve__` directly). Platforms without an extensions payload fall back to the
198
+ * historical parser-package specifier.
199
+ *
200
+ * @returns {Promise<string>}
201
+ */
202
+ async function contentCollectionSrc(): Promise<string> {
203
+ const extensions = await loadExtensions();
204
+ for (const ext of extensions) {
205
+ const cls = ext.classes?.find((c) => c.name === "ContentCollection");
206
+ if (cls) {
207
+ return cls.path;
208
+ }
209
+ }
210
+ return "@jxsuite/parser/ContentCollection.class.json";
211
+ }
212
+
187
213
  /**
188
214
  * Resolve a ContentCollection for a content type via the PAL, falling back to a plain dev-proxy
189
215
  * fetch on platforms that predate `resolveClass`.
@@ -191,10 +217,10 @@ async function resolveParamValues(pathsDef: JxPathsDef): Promise<ParamValues> {
191
217
  * @param {string} contentType
192
218
  * @returns {Promise<unknown>}
193
219
  */
194
- function resolveContentCollection(contentType: string) {
220
+ async function resolveContentCollection(contentType: string) {
195
221
  const body = {
196
222
  $prototype: "ContentCollection",
197
- $src: "@jxsuite/parser/ContentCollection.class.json",
223
+ $src: await contentCollectionSrc(),
198
224
  contentType,
199
225
  };
200
226
  const platform = getPlatform();