@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,417 @@
1
+ /**
2
+ * Per-tab grid orchestration — no DOM, no grid engine.
3
+ *
4
+ * Owns the source, the reactive load state, and the edit buffer for one grid tab. Created inside
5
+ * the tab's effectScope so everything (dirty mirroring, history delegate, fs subscription, the
6
+ * registry entry) tears down when the tab closes. The buffer's dirtiness mirrors onto
7
+ * `tab.doc.dirty`, which makes the existing tab-strip dot and close-confirm flows work unchanged;
8
+ * undo/redo registers as the tab's HistoryDelegate, so Cmd-Z and the toolbar buttons route here
9
+ * without any shortcut edits.
10
+ *
11
+ * Save is explicit and batched: validate required cells → confirm deletes → commit through the
12
+ * source → clear/re-baseline what succeeded, keep failures dirty with errors, mark stale rows.
13
+ */
14
+ import { effect, onScopeDispose, reactive, toRaw } from "../reactivity";
15
+ import { getPlatform } from "../platform";
16
+ import { setHistoryDelegate } from "../tabs/transact";
17
+ import { isRecentLocal } from "../files/fs-events";
18
+ import { statusMessage } from "../panels/statusbar";
19
+ import { showConfirmDialog } from "../ui/layers";
20
+ import { showProgressModal } from "../ui/progress-modal";
21
+ import { errorMessage } from "@jxsuite/schema/parse";
22
+ import { createEditBuffer } from "./edit-buffer";
23
+ import type { EditBuffer } from "./edit-buffer";
24
+ import type {
25
+ CommitResult,
26
+ GridCellValue,
27
+ GridColumn,
28
+ GridEditBatch,
29
+ GridQuery,
30
+ GridRow,
31
+ GridSource,
32
+ } from "./grid-source";
33
+ import type { Tab } from "../tabs/tab";
34
+
35
+ export interface GridControllerState {
36
+ columns: GridColumn[];
37
+ /** Committed rows as last loaded/saved — baselines live here. */
38
+ rows: GridRow[];
39
+ total: number;
40
+ loading: boolean;
41
+ saving: boolean;
42
+ error: string | null;
43
+ query: GridQuery;
44
+ }
45
+
46
+ /** What the engine wrapper exposes back to the controller for out-of-band data changes. */
47
+ export interface GridViewBinding {
48
+ /** Re-pull effectiveRows() into the table (undo/redo, save, refresh, row add/delete). */
49
+ refreshData: () => void;
50
+ }
51
+
52
+ export interface GridController {
53
+ tab: Tab;
54
+ source: GridSource;
55
+ state: GridControllerState;
56
+ buffer: EditBuffer;
57
+ load: () => Promise<void>;
58
+ /** Reload from the source; asks before discarding pending edits. */
59
+ refresh: () => Promise<void>;
60
+ save: () => Promise<void>;
61
+ /** Query change (connector paging/sorting) — reloads rows. */
62
+ setQuery: (query: GridQuery) => Promise<void>;
63
+ /** Plain row objects (committed + pending overlay + pending inserts) for the grid engine. */
64
+ effectiveRows: () => Record<string, GridCellValue>[];
65
+ /** Current file text including pending edits, for source-mode display (file grids only). */
66
+ serializeForSource: () => Promise<string> | null;
67
+ /** Attach/detach the engine wrapper. Edits flowing FROM the table never round-trip back. */
68
+ bindView: (view: GridViewBinding | null) => void;
69
+ /** Buffer a new row (pending insert) and sync the view. Returns its temp key. */
70
+ addRow: (cells?: Record<string, GridCellValue>) => string;
71
+ /** Buffer row deletions as one undo group and sync the view. */
72
+ deleteRows: (rowKeys: string[]) => void;
73
+ /** Drop one row's pending edits (not undoable) and sync the view. */
74
+ discardRow: (rowKey: string) => void;
75
+ /** Replace text across editable string/text cells (one undo group). Returns cells changed. */
76
+ replaceAll: (find: string, replace: string) => number;
77
+ }
78
+
79
+ /** Row-object key carrying the grid row identity through the engine. */
80
+ export const ROW_KEY_FIELD = "__key";
81
+
82
+ const _controllers = new WeakMap<object, GridController>();
83
+
84
+ /** The grid controller for a tab, if it is a grid tab. */
85
+ export function getGridController(tab: Tab | null): GridController | null {
86
+ return tab ? (_controllers.get(toRaw(tab as unknown as object)) ?? null) : null;
87
+ }
88
+
89
+ /** Create and register the controller for a grid tab. One per tab; lives in the tab's scope. */
90
+ export function createGridController(tab: Tab, source: GridSource): GridController {
91
+ const state: GridControllerState = reactive({
92
+ columns: [] as GridColumn[],
93
+ error: null as string | null,
94
+ loading: false,
95
+ query: {} as GridQuery,
96
+ rows: [] as GridRow[],
97
+ saving: false,
98
+ total: 0,
99
+ }) as GridControllerState;
100
+
101
+ let rowByKey = new Map<string, GridRow>();
102
+ const indexRows = (rows: GridRow[]) => {
103
+ rowByKey = new Map(rows.map((row) => [row.key, row]));
104
+ };
105
+
106
+ const buffer = createEditBuffer({
107
+ resolveBaseline: (rowKey, field) => rowByKey.get(rowKey)?.cells[field] ?? null,
108
+ });
109
+
110
+ const reloadRows = async () => {
111
+ const result = await source.rows(state.query);
112
+ state.rows = result.rows;
113
+ state.total = result.total;
114
+ indexRows(result.rows);
115
+ };
116
+
117
+ /** Missing required cells block the save (marked as row/cell errors, like a failed commit). */
118
+ const requiredViolations = (batch: GridEditBatch): CommitResult | null => {
119
+ const required = state.columns.filter((c) => c.required && c.editable);
120
+ if (required.length === 0) {
121
+ return null;
122
+ }
123
+ const cells = batch.cells
124
+ .filter((cell) => cell.value === null && required.some((c) => c.field === cell.field))
125
+ .map((cell) => ({ error: "Required", field: cell.field, ok: false, rowKey: cell.rowKey }));
126
+ const inserts = batch.inserts
127
+ .filter((insert) =>
128
+ required.some((c) => insert.cells[c.field] === null || insert.cells[c.field] === undefined),
129
+ )
130
+ .map((insert) => {
131
+ const missing = required
132
+ .filter((c) => insert.cells[c.field] === null || insert.cells[c.field] === undefined)
133
+ .map((c) => c.field)
134
+ .join(", ");
135
+ return { error: `Missing required: ${missing}`, ok: false, tempKey: insert.tempKey };
136
+ });
137
+ if (cells.length === 0 && inserts.length === 0) {
138
+ return null;
139
+ }
140
+ return { cells, deletes: [], inserts };
141
+ };
142
+
143
+ let viewBinding: GridViewBinding | null = null;
144
+ const syncView = () => viewBinding?.refreshData();
145
+
146
+ const controller: GridController = {
147
+ addRow(cells = {}) {
148
+ const tempKey = buffer.insertRow(cells);
149
+ syncView();
150
+ return tempKey;
151
+ },
152
+
153
+ bindView(view) {
154
+ viewBinding = view;
155
+ },
156
+
157
+ buffer,
158
+
159
+ deleteRows(rowKeys) {
160
+ if (rowKeys.length === 0) {
161
+ return;
162
+ }
163
+ buffer.group(() => {
164
+ for (const rowKey of rowKeys) {
165
+ buffer.deleteRow(rowKey);
166
+ }
167
+ });
168
+ syncView();
169
+ },
170
+
171
+ discardRow(rowKey) {
172
+ buffer.discardRow(rowKey);
173
+ syncView();
174
+ },
175
+
176
+ replaceAll(find, replace) {
177
+ if (find === "") {
178
+ return 0;
179
+ }
180
+ const textColumns = state.columns.filter(
181
+ (column) => column.editable && (column.kind === "string" || column.kind === "text"),
182
+ );
183
+ const rowKeys = [...state.rows.map((row) => row.key), ...buffer.state.inserts.keys()];
184
+ let changed = 0;
185
+ buffer.group(() => {
186
+ for (const rowKey of rowKeys) {
187
+ if (buffer.rowState(rowKey) === "pending-delete") {
188
+ continue;
189
+ }
190
+ for (const column of textColumns) {
191
+ const value = buffer.effectiveValue(rowKey, column.field);
192
+ if (typeof value === "string" && value.includes(find)) {
193
+ buffer.setCell(rowKey, column.field, value.replaceAll(find, replace));
194
+ changed += 1;
195
+ }
196
+ }
197
+ }
198
+ });
199
+ if (changed > 0) {
200
+ syncView();
201
+ }
202
+ return changed;
203
+ },
204
+
205
+ effectiveRows() {
206
+ // Detach values from reactive proxies — the grid engine must never hold live proxies.
207
+ const plain = (value: GridCellValue): GridCellValue =>
208
+ Array.isArray(value)
209
+ ? [...value]
210
+ : value !== null && typeof value === "object"
211
+ ? { $ref: value.$ref }
212
+ : value;
213
+ const rowObject = (key: string) => {
214
+ const cells: Record<string, GridCellValue> = { [ROW_KEY_FIELD]: key };
215
+ for (const column of state.columns) {
216
+ cells[column.field] = plain(buffer.effectiveValue(key, column.field));
217
+ }
218
+ return cells;
219
+ };
220
+ const rows = state.rows.map((row) => rowObject(row.key));
221
+ for (const [tempKey] of buffer.state.inserts) {
222
+ rows.push(rowObject(tempKey));
223
+ }
224
+ return rows;
225
+ },
226
+
227
+ async load() {
228
+ state.loading = true;
229
+ state.error = null;
230
+ try {
231
+ state.columns = await source.columns();
232
+ await reloadRows();
233
+ } catch (error) {
234
+ state.error = errorMessage(error);
235
+ } finally {
236
+ state.loading = false;
237
+ }
238
+ syncView();
239
+ },
240
+
241
+ async refresh() {
242
+ if (buffer.isDirty()) {
243
+ const confirmed = await showConfirmDialog(
244
+ "Refresh Grid",
245
+ `Discard ${buffer.dirtyCount()} pending change(s) and reload?`,
246
+ { confirmLabel: "Discard & Reload", destructive: true },
247
+ );
248
+ if (!confirmed) {
249
+ return;
250
+ }
251
+ }
252
+ buffer.reset();
253
+ await source.refresh?.();
254
+ await this.load();
255
+ },
256
+
257
+ async save() {
258
+ if (state.saving) {
259
+ return;
260
+ }
261
+ const batch = buffer.buildBatch();
262
+ const changeCount = batch.cells.length + batch.inserts.length + batch.deletes.length;
263
+ if (changeCount === 0) {
264
+ statusMessage("No grid changes to save");
265
+ return;
266
+ }
267
+ const violations = requiredViolations(batch);
268
+ if (violations) {
269
+ buffer.applyCommitResult(violations);
270
+ statusMessage("Required cells are empty — fix the marked rows and save again", 5000);
271
+ return;
272
+ }
273
+ if (batch.deletes.length > 0) {
274
+ const confirmed = await showConfirmDialog(
275
+ "Delete Rows",
276
+ `Save will permanently delete ${batch.deletes.length} row(s). Continue?`,
277
+ { confirmLabel: "Delete & Save", destructive: true },
278
+ );
279
+ if (!confirmed) {
280
+ return;
281
+ }
282
+ }
283
+
284
+ const affectedRows =
285
+ new Set(batch.cells.map((c) => c.rowKey)).size +
286
+ batch.inserts.length +
287
+ batch.deletes.length;
288
+ const progress = affectedRows > 5 ? showProgressModal({ title: "Saving grid" }) : null;
289
+ state.saving = true;
290
+ try {
291
+ const result = await source.commit(batch);
292
+
293
+ // Move baselines for saved cells before the buffer clears their pending entries.
294
+ const cellKey = (rowKey: string, field: string) => `${rowKey}${field}`;
295
+ const batchCells = new Map(batch.cells.map((c) => [cellKey(c.rowKey, c.field), c]));
296
+ for (const outcome of result.cells) {
297
+ const cell = batchCells.get(cellKey(outcome.rowKey, outcome.field));
298
+ if (outcome.ok && cell) {
299
+ const row = rowByKey.get(outcome.rowKey);
300
+ if (row) {
301
+ row.cells[outcome.field] = cell.value;
302
+ }
303
+ }
304
+ }
305
+ buffer.applyCommitResult(result);
306
+
307
+ const structural = result.inserts.some((r) => r.ok) || result.deletes.some((r) => r.ok);
308
+ if (structural) {
309
+ await reloadRows();
310
+ }
311
+
312
+ const okCount =
313
+ result.cells.filter((r) => r.ok).length +
314
+ result.inserts.filter((r) => r.ok).length +
315
+ result.deletes.filter((r) => r.ok).length;
316
+ const failed = changeCount - okCount;
317
+ const staleCount =
318
+ result.cells.filter((r) => r.stale).length + result.deletes.filter((r) => r.stale).length;
319
+ statusMessage(
320
+ failed === 0
321
+ ? `Saved ${okCount} change(s)`
322
+ : `Saved ${okCount} · ${failed} failed${staleCount ? ` (${staleCount} stale)` : ""} — kept pending`,
323
+ failed === 0 ? 3000 : 6000,
324
+ );
325
+ } catch (error) {
326
+ statusMessage(`Save error: ${errorMessage(error)}`, 6000);
327
+ } finally {
328
+ state.saving = false;
329
+ progress?.done();
330
+ syncView();
331
+ }
332
+ },
333
+
334
+ serializeForSource() {
335
+ if (!source.serializeForSource) {
336
+ return null;
337
+ }
338
+ return source.serializeForSource(buffer.buildBatch());
339
+ },
340
+
341
+ async setQuery(query: GridQuery) {
342
+ state.query = query;
343
+ state.loading = true;
344
+ try {
345
+ await reloadRows();
346
+ } catch (error) {
347
+ state.error = errorMessage(error);
348
+ } finally {
349
+ state.loading = false;
350
+ }
351
+ syncView();
352
+ },
353
+
354
+ source,
355
+ state,
356
+ tab,
357
+ };
358
+
359
+ tab.scope.run(() => {
360
+ // Buffer dirtiness IS the tab's dirty flag — tab-strip dot and close-confirm come for free.
361
+ effect(() => {
362
+ tab.doc.dirty = buffer.isDirty();
363
+ });
364
+
365
+ setHistoryDelegate(tab, {
366
+ canRedo: () => buffer.canRedo(),
367
+ canUndo: () => buffer.canUndo(),
368
+ redo: () => {
369
+ buffer.redo();
370
+ syncView();
371
+ },
372
+ undo: () => {
373
+ buffer.undo();
374
+ syncView();
375
+ },
376
+ });
377
+
378
+ const platform = getPlatform();
379
+ if (platform.subscribeFileEvents && source.backingPaths) {
380
+ const unsubscribe = platform.subscribeFileEvents((events) => {
381
+ const backing = source.backingPaths!();
382
+ const hitKeys = new Set<string>();
383
+ for (const event of events) {
384
+ if (event.isDir) {
385
+ continue;
386
+ }
387
+ const path = event.path.replaceAll("\\", "/");
388
+ const rowKey = backing.get(path);
389
+ if (rowKey !== undefined && !isRecentLocal(path)) {
390
+ hitKeys.add(rowKey);
391
+ }
392
+ }
393
+ if (hitKeys.size === 0) {
394
+ return;
395
+ }
396
+ if (buffer.isDirty()) {
397
+ const keys = hitKeys.has("*") ? state.rows.map((row) => row.key) : [...hitKeys];
398
+ buffer.markStale(keys);
399
+ statusMessage("Grid rows changed on disk — marked stale (refresh to reload)", 5000);
400
+ syncView();
401
+ } else {
402
+ void controller.refresh();
403
+ }
404
+ });
405
+ onScopeDispose(unsubscribe);
406
+ }
407
+
408
+ onScopeDispose(() => {
409
+ setHistoryDelegate(tab, null);
410
+ _controllers.delete(toRaw(tab as unknown as object));
411
+ source.dispose?.();
412
+ });
413
+ });
414
+
415
+ _controllers.set(toRaw(tab as unknown as object), controller);
416
+ return controller;
417
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Grid column-layout persistence — widths and order, per grid id, in localStorage.
3
+ *
4
+ * Pure besides the storage read/write: applyGridLayout reorders/undoes nothing destructive, it just
5
+ * returns a new column-def array with saved widths applied and saved order first (columns unknown
6
+ * to the saved layout keep their relative position at the end; saved fields that no longer exist
7
+ * are ignored — schemas drift).
8
+ */
9
+
10
+ export interface GridLayout {
11
+ /** Column order as field names, leftmost first. */
12
+ order?: string[];
13
+ /** Field → width in px. */
14
+ widths?: Record<string, number>;
15
+ }
16
+
17
+ const STORAGE_PREFIX = "jx-grid-layout:";
18
+
19
+ function storage(): Storage | null {
20
+ try {
21
+ return globalThis.localStorage ?? null;
22
+ } catch {
23
+ return null; // Storage disabled (privacy mode) — layouts just don't persist.
24
+ }
25
+ }
26
+
27
+ /** The saved layout for a grid id, or null. */
28
+ export function loadGridLayout(gridId: string): GridLayout | null {
29
+ const raw = storage()?.getItem(STORAGE_PREFIX + gridId);
30
+ if (!raw) {
31
+ return null;
32
+ }
33
+ try {
34
+ const parsed = JSON.parse(raw) as GridLayout;
35
+ return typeof parsed === "object" && parsed !== null ? parsed : null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /** Merge a partial layout change into the saved layout for a grid id. */
42
+ export function saveGridLayout(gridId: string, change: GridLayout): void {
43
+ const store = storage();
44
+ if (!store) {
45
+ return;
46
+ }
47
+ const current = loadGridLayout(gridId) ?? {};
48
+ const next: GridLayout = {
49
+ ...((change.order ?? current.order) ? { order: change.order ?? current.order } : {}),
50
+ widths: { ...current.widths, ...change.widths },
51
+ };
52
+ store.setItem(STORAGE_PREFIX + gridId, JSON.stringify(next));
53
+ }
54
+
55
+ /** Drop the saved layout for a grid id. */
56
+ export function clearGridLayout(gridId: string): void {
57
+ storage()?.removeItem(STORAGE_PREFIX + gridId);
58
+ }
59
+
60
+ /** Apply a saved layout to column defs: saved widths win; saved order leads, unknowns follow. */
61
+ export function applyGridLayout<
62
+ T extends { field?: string | undefined; width?: number | undefined },
63
+ >(defs: T[], layout: GridLayout | null): T[] {
64
+ if (!layout) {
65
+ return defs;
66
+ }
67
+ const widened = defs.map((def) => {
68
+ const width = def.field ? layout.widths?.[def.field] : undefined;
69
+ return width ? { ...def, width } : def;
70
+ });
71
+ if (!layout.order?.length) {
72
+ return widened;
73
+ }
74
+ const rank = new Map(layout.order.map((field, i) => [field, i]));
75
+ return widened
76
+ .map((def, index) => ({ def, index }))
77
+ .toSorted((a, b) => {
78
+ const ra = rank.get(a.def.field ?? "") ?? layout.order!.length + a.index;
79
+ const rb = rank.get(b.def.field ?? "") ?? layout.order!.length + b.index;
80
+ return ra - rb;
81
+ })
82
+ .map(({ def }) => def);
83
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Grid tab openers — create (or activate) the tab + controller pair for a grid surface.
3
+ *
4
+ * CSV files open as REAL file tabs (id = path) whose default canvas mode is "grid" with a Monaco
5
+ * "source" alternate; collection/pages/connector grids (later phases) open as virtual tabs with
6
+ * `grid://` ids. All openers dedupe by tab id.
7
+ */
8
+ import { html } from "lit-html";
9
+ import { activateTab, openTab, workspace } from "../workspace/workspace";
10
+ import { formatForPath, loadFormats } from "../format/format-host";
11
+ import { openModal } from "../ui/layers";
12
+ import { dataSurfaceAvailable, fetchConnections } from "../services/data-service";
13
+ import { createGridController } from "./grid-controller";
14
+ import { createCsvFileSource } from "./sources/csv-file-source";
15
+ import {
16
+ collectionDirs,
17
+ createCollectionSource,
18
+ createPagesSource,
19
+ } from "./sources/content-source";
20
+ import { createConnectorSource } from "./sources/connector-source";
21
+ import { makeGridTabId } from "./grid-source";
22
+ import type { GridSource } from "./grid-source";
23
+ import type { Tab } from "../tabs/tab";
24
+
25
+ /** Placeholder document for grid tabs — the grid never reads it; save routes to the controller. */
26
+ const GRID_STUB_DOCUMENT = { children: [], tagName: "div" };
27
+
28
+ /** Open (or activate) a `.csv` file as a grid tab. */
29
+ export async function openCsvGridTab(path: string): Promise<Tab> {
30
+ const existing = workspace.tabs.get(path);
31
+ if (existing) {
32
+ activateTab(path);
33
+ return existing;
34
+ }
35
+
36
+ // Best effort: the format name labels the tab's source mode; the grid works without it.
37
+ try {
38
+ await loadFormats();
39
+ } catch {
40
+ // Format registry unavailable (e.g. no dev server) — grid mode still works.
41
+ }
42
+
43
+ const source = createCsvFileSource(path);
44
+ const tab = openTab({
45
+ capabilities: { modes: ["grid", "source"] },
46
+ document: structuredClone(GRID_STUB_DOCUMENT),
47
+ documentPath: path,
48
+ id: path,
49
+ sourceFormat: formatForPath(path)?.name ?? null,
50
+ });
51
+ const controller = createGridController(tab, source);
52
+ void controller.load();
53
+ return tab;
54
+ }
55
+
56
+ /**
57
+ * Open a virtual grid tab for a source (collections now; more kinds later). Callers dedupe by the
58
+ * same `makeGridTabId` id before constructing the source.
59
+ */
60
+ function openVirtualGridTab(source: GridSource): Tab {
61
+ const tab = openTab({
62
+ capabilities: { modes: ["grid"] },
63
+ document: structuredClone(GRID_STUB_DOCUMENT),
64
+ documentPath: null,
65
+ id: source.id,
66
+ });
67
+ const controller = createGridController(tab, source);
68
+ void controller.load();
69
+ return tab;
70
+ }
71
+
72
+ /** Open (or activate) the grid tab for a content collection. */
73
+ export function openCollectionGrid(typeName: string): Tab {
74
+ const id = makeGridTabId({ kind: "collection", name: typeName });
75
+ const existing = workspace.tabs.get(id);
76
+ if (existing) {
77
+ activateTab(id);
78
+ return existing;
79
+ }
80
+ return openVirtualGridTab(createCollectionSource(typeName));
81
+ }
82
+
83
+ /** Open (or activate) the pages metadata grid. */
84
+ export function openPagesGrid(): Tab {
85
+ const id = makeGridTabId({ kind: "pages" });
86
+ const existing = workspace.tabs.get(id);
87
+ if (existing) {
88
+ activateTab(id);
89
+ return existing;
90
+ }
91
+ return openVirtualGridTab(createPagesSource());
92
+ }
93
+
94
+ /** Open (or activate) the grid tab for a connector table. */
95
+ export function openConnectorGrid(connection: string | undefined, table: string): Tab {
96
+ const id = makeGridTabId({ connection: connection ?? "default", kind: "data", table });
97
+ const existing = workspace.tabs.get(id);
98
+ if (existing) {
99
+ activateTab(id);
100
+ return existing;
101
+ }
102
+ return openVirtualGridTab(createConnectorSource(connection, table));
103
+ }
104
+
105
+ /**
106
+ * Source picker — one dialog listing every grid-able source: pages, content collections, and (when
107
+ * the platform serves the data surface) each connection's tables.
108
+ */
109
+ export async function openGridSourcePicker(): Promise<void> {
110
+ const collections = collectionDirs();
111
+ let connections: { name: string; tables: string[] }[] = [];
112
+ if (dataSurfaceAvailable()) {
113
+ const response = await fetchConnections().catch(() => null);
114
+ connections = response?.connections ?? [];
115
+ }
116
+
117
+ const handle = openModal(
118
+ html`<sp-dialog-wrapper
119
+ open
120
+ dismissable
121
+ underlay
122
+ headline="Open Grid"
123
+ @close=${() => handle.close()}
124
+ >
125
+ <sp-menu class="jx-grid-picker">
126
+ <sp-menu-group>
127
+ <span slot="header">Project</span>
128
+ <sp-menu-item
129
+ @click=${() => {
130
+ handle.close();
131
+ openPagesGrid();
132
+ }}
133
+ >Pages</sp-menu-item
134
+ >
135
+ ${collections.map(
136
+ ({ name }) =>
137
+ html`<sp-menu-item
138
+ @click=${() => {
139
+ handle.close();
140
+ openCollectionGrid(name);
141
+ }}
142
+ >Collection: ${name}</sp-menu-item
143
+ >`,
144
+ )}
145
+ </sp-menu-group>
146
+ ${connections.map(
147
+ (conn) =>
148
+ html`<sp-menu-group>
149
+ <span slot="header">Data · ${conn.name}</span>
150
+ ${conn.tables.length === 0
151
+ ? html`<sp-menu-item disabled>No tables — push a schema first</sp-menu-item>`
152
+ : conn.tables.map(
153
+ (table) =>
154
+ html`<sp-menu-item
155
+ @click=${() => {
156
+ handle.close();
157
+ openConnectorGrid(conn.name, table);
158
+ }}
159
+ >${table}</sp-menu-item
160
+ >`,
161
+ )}
162
+ </sp-menu-group>`,
163
+ )}
164
+ </sp-menu>
165
+ </sp-dialog-wrapper>`,
166
+ );
167
+ }