@jxsuite/studio 1.0.0 → 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 (86) 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 +53069 -31746
  5. package/dist/studio.js.map +109 -74
  6. package/package.json +11 -9
  7. package/src/canvas/canvas-live-render.ts +21 -2
  8. package/src/canvas/canvas-render.ts +35 -16
  9. package/src/canvas/canvas-utils.ts +118 -72
  10. package/src/canvas/iframe-entry.ts +20 -0
  11. package/src/canvas/iframe-eval.ts +155 -0
  12. package/src/canvas/iframe-host.ts +131 -5
  13. package/src/canvas/iframe-inline-edit.ts +107 -1
  14. package/src/canvas/iframe-protocol.ts +43 -3
  15. package/src/canvas/iframe-render.ts +18 -0
  16. package/src/collab/collab-session.ts +23 -8
  17. package/src/collab/collab-state.ts +6 -3
  18. package/src/component-props.ts +104 -0
  19. package/src/editor/inline-edit-apply.ts +36 -1
  20. package/src/editor/inline-edit.ts +58 -1
  21. package/src/editor/shortcuts.ts +41 -12
  22. package/src/files/file-ops.ts +14 -0
  23. package/src/files/files.ts +53 -1
  24. package/src/grid/cell-editors.ts +288 -0
  25. package/src/grid/cell-popovers.ts +108 -0
  26. package/src/grid/csv-codec.ts +122 -0
  27. package/src/grid/edit-buffer.ts +490 -0
  28. package/src/grid/grid-controller.ts +417 -0
  29. package/src/grid/grid-layout.ts +83 -0
  30. package/src/grid/grid-open.ts +167 -0
  31. package/src/grid/grid-panel.ts +302 -0
  32. package/src/grid/grid-source.ts +210 -0
  33. package/src/grid/grid-view.ts +367 -0
  34. package/src/grid/schema-columns.ts +261 -0
  35. package/src/grid/sources/connector-source.ts +217 -0
  36. package/src/grid/sources/content-source.ts +542 -0
  37. package/src/grid/sources/csv-file-source.ts +247 -0
  38. package/src/grid/tabulator-tables.d.ts +120 -0
  39. package/src/panels/block-action-bar.ts +8 -3
  40. package/src/panels/chat-panel.ts +98 -0
  41. package/src/panels/data-grid.ts +9 -387
  42. package/src/panels/editors.ts +43 -17
  43. package/src/panels/events-panel.ts +137 -44
  44. package/src/panels/formula-workspace.ts +379 -0
  45. package/src/panels/frontmatter-fields.ts +231 -0
  46. package/src/panels/frontmatter-panel.ts +132 -0
  47. package/src/panels/git-panel.ts +1 -1
  48. package/src/panels/head-panel.ts +11 -175
  49. package/src/panels/properties-panel.ts +154 -144
  50. package/src/panels/right-panel.ts +9 -47
  51. package/src/panels/signals-panel.ts +115 -23
  52. package/src/panels/statement-editor.ts +710 -0
  53. package/src/panels/style-panel.ts +25 -1
  54. package/src/panels/stylebook-panel.ts +0 -2
  55. package/src/panels/tab-bar.ts +175 -6
  56. package/src/panels/tab-strip.ts +62 -6
  57. package/src/panels/toolbar.ts +37 -3
  58. package/src/services/ai-project-tools.ts +541 -0
  59. package/src/services/ai-session-store.ts +28 -0
  60. package/src/services/ai-system-prompt.ts +194 -24
  61. package/src/services/ai-tools.ts +88 -21
  62. package/src/services/automation.ts +16 -0
  63. package/src/services/document-assistant.ts +81 -11
  64. package/src/services/gated-registry.ts +72 -0
  65. package/src/services/live-preview.ts +0 -0
  66. package/src/services/preview-eval.ts +68 -0
  67. package/src/services/project-adoption.ts +31 -0
  68. package/src/site-context.ts +2 -0
  69. package/src/store.ts +4 -0
  70. package/src/studio.ts +83 -52
  71. package/src/tabs/patch-ops.ts +25 -0
  72. package/src/tabs/tab.ts +39 -1
  73. package/src/tabs/transact.ts +60 -13
  74. package/src/types.ts +12 -0
  75. package/src/ui/dynamic-slot.ts +272 -0
  76. package/src/ui/expression-editor.ts +423 -125
  77. package/src/ui/field-row.ts +5 -0
  78. package/src/ui/formula-catalog.ts +557 -0
  79. package/src/ui/formula-chips.ts +216 -0
  80. package/src/ui/formula-palette.ts +211 -0
  81. package/src/ui/layers.ts +40 -0
  82. package/src/ui/media-picker.ts +1 -1
  83. package/src/ui/panel-resize.ts +15 -1
  84. package/src/utils/preview-format.ts +26 -0
  85. package/src/view.ts +4 -4
  86. package/src/workspace/workspace.ts +14 -0
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Grid canvas panel — the lit shell rendered into #canvas-wrap when a tab's canvasMode is "grid".
3
+ *
4
+ * Follows the stylebook/source pattern: canvas-render calls renderGridMode() on entry and
5
+ * detachGridPanel() in its mode-change teardown. The shell (toolbar + host div) re-renders from a
6
+ * panel-local effect that tracks the controller's reactive state, so the Save badge, row count, and
7
+ * loading/error surfaces stay live without renderCanvas involvement. The Tabulator view is created
8
+ * once per tab entry, after the controller has loaded columns, and destroyed on detach — all grid
9
+ * data survives in the controller/buffer, so a rebuild on re-entry is cheap.
10
+ */
11
+ import { html, render as litRender, nothing } from "lit-html";
12
+ import { ref } from "lit-html/directives/ref.js";
13
+ import { effect, effectScope } from "../reactivity";
14
+ import { renderPopover } from "../ui/layers";
15
+ import { statusMessage } from "../panels/statusbar";
16
+ import { rectOf } from "../utils/geometry";
17
+ import { createGridController, getGridController } from "./grid-controller";
18
+ import { createCsvFileSource } from "./sources/csv-file-source";
19
+ import { createGridView } from "./grid-view";
20
+ import { parseGridTabId } from "./grid-source";
21
+ import type { EffectScope } from "@vue/reactivity";
22
+ import type { GridController } from "./grid-controller";
23
+ import type { GridView } from "./grid-view";
24
+ import type { Tab } from "../tabs/tab";
25
+
26
+ interface ActiveGridPanel {
27
+ tabId: string;
28
+ scope: EffectScope;
29
+ view: GridView | null;
30
+ wrap: HTMLElement;
31
+ }
32
+
33
+ let active: ActiveGridPanel | null = null;
34
+
35
+ /** Destroy the live grid view/effects (canvas-render teardown + tab switches). */
36
+ export function detachGridPanel() {
37
+ if (!active) {
38
+ return;
39
+ }
40
+ active.view?.destroy();
41
+ active.scope.stop();
42
+ active = null;
43
+ }
44
+
45
+ /** Whether the grid panel is live for this tab (canvas-render fast-path guard). */
46
+ export function gridPanelMounted(tab: Tab): boolean {
47
+ return active !== null && active.tabId === tab.id && active.wrap.isConnected;
48
+ }
49
+
50
+ function toolbarTpl(controller: GridController, getView: () => GridView | null) {
51
+ const { state } = controller;
52
+ const dirty = controller.buffer.dirtyCount();
53
+ const sourceRef = parseGridTabId(controller.source.id);
54
+ const lossyNote =
55
+ sourceRef?.kind === "collection" || sourceRef?.kind === "pages"
56
+ ? html`<span
57
+ class="jx-grid-note"
58
+ title="Saving re-serializes frontmatter; YAML comments and key order are not preserved."
59
+ >
60
+ rewrites frontmatter
61
+ </span>`
62
+ : nothing;
63
+
64
+ return html`
65
+ <div class="jx-grid-toolbar">
66
+ <sp-button
67
+ size="s"
68
+ variant="accent"
69
+ ?disabled=${dirty === 0 || state.saving}
70
+ @click=${() => void controller.save()}
71
+ >
72
+ Save${dirty > 0 ? ` (${dirty})` : ""}
73
+ </sp-button>
74
+ <sp-action-button
75
+ size="s"
76
+ quiet
77
+ title="Reload from source"
78
+ ?disabled=${state.loading || state.saving}
79
+ @click=${() => void controller.refresh()}
80
+ >
81
+ Refresh
82
+ </sp-action-button>
83
+ <sp-divider size="s" vertical></sp-divider>
84
+ ${controller.source.capabilities.insert
85
+ ? html`<sp-action-button
86
+ size="s"
87
+ quiet
88
+ title="Add a row (saved on Save)"
89
+ @click=${() => controller.addRow()}
90
+ >
91
+ Add Row
92
+ </sp-action-button>`
93
+ : nothing}
94
+ ${controller.source.capabilities.delete
95
+ ? html`<sp-action-button
96
+ size="s"
97
+ quiet
98
+ title="Mark selected rows for deletion"
99
+ @click=${() => controller.deleteRows(getView()?.getSelectedRowKeys() ?? [])}
100
+ >
101
+ Delete Rows
102
+ </sp-action-button>`
103
+ : nothing}
104
+ <sp-action-button
105
+ size="s"
106
+ quiet
107
+ title="Fill the selected range down from its first row (Ctrl/Cmd-D)"
108
+ @click=${() => getView()?.fillDown()}
109
+ >
110
+ Fill Down
111
+ </sp-action-button>
112
+ <sp-action-button
113
+ size="s"
114
+ quiet
115
+ title="Find & replace across text cells"
116
+ @click=${(e: MouseEvent) => openReplacePopover(controller, e.currentTarget as HTMLElement)}
117
+ >
118
+ Replace
119
+ </sp-action-button>
120
+ <sp-search
121
+ size="s"
122
+ quiet
123
+ placeholder="Filter rows"
124
+ @input=${(e: Event) => getView()?.setSearch((e.target as HTMLInputElement).value)}
125
+ @submit=${(e: Event) => e.preventDefault()}
126
+ ></sp-search>
127
+ <span class="jx-grid-spacer"></span>
128
+ ${lossyNote} ${controller.source.capabilities.remotePaging ? pagerTpl(controller) : nothing}
129
+ <span class="jx-grid-count">
130
+ ${state.loading
131
+ ? "Loading…"
132
+ : state.error
133
+ ? html`<span class="jx-grid-error-text">${state.error}</span>`
134
+ : `${state.total} row${state.total === 1 ? "" : "s"}`}
135
+ </span>
136
+ </div>
137
+ `;
138
+ }
139
+
140
+ /** Find & replace popover — buffers all replacements as one undo group. */
141
+ function openReplacePopover(controller: GridController, anchor: HTMLElement) {
142
+ const rect = rectOf(anchor);
143
+ let find = "";
144
+ let replace = "";
145
+ const handle = renderPopover(
146
+ html`<sp-popover
147
+ open
148
+ class="jx-grid-replace-popover"
149
+ style="position:fixed;z-index:10000;left:${Math.max(4, rect.left)}px;top:${rect.bottom + 4}px"
150
+ >
151
+ <div class="jx-grid-cell-popover-body">
152
+ <div class="jx-grid-cell-popover-title">Find &amp; Replace</div>
153
+ <input
154
+ class="jx-grid-input"
155
+ placeholder="Find…"
156
+ @input=${(e: Event) => (find = (e.target as HTMLInputElement).value)}
157
+ />
158
+ <input
159
+ class="jx-grid-input"
160
+ placeholder="Replace with…"
161
+ @input=${(e: Event) => (replace = (e.target as HTMLInputElement).value)}
162
+ />
163
+ <div class="jx-grid-cell-popover-actions">
164
+ <sp-button
165
+ size="s"
166
+ variant="accent"
167
+ @click=${() => {
168
+ const changed = controller.replaceAll(find, replace);
169
+ statusMessage(
170
+ changed === 0
171
+ ? "No matches"
172
+ : `Replaced in ${changed} cell${changed === 1 ? "" : "s"} — save to apply`,
173
+ );
174
+ if (changed > 0) {
175
+ handle.dismiss();
176
+ }
177
+ }}
178
+ >
179
+ Replace All
180
+ </sp-button>
181
+ <sp-button size="s" variant="secondary" @click=${() => handle.dismiss()}>
182
+ Cancel
183
+ </sp-button>
184
+ </div>
185
+ </div>
186
+ </sp-popover>`,
187
+ { dismissOnOutsideClick: true },
188
+ );
189
+ }
190
+
191
+ /** Prev/Next pager for remote-paged sources (connector tables). */
192
+ function pagerTpl(controller: GridController) {
193
+ const { state } = controller;
194
+ const limit = state.query.limit ?? 50;
195
+ const offset = state.query.offset ?? 0;
196
+ const from = state.total === 0 ? 0 : offset + 1;
197
+ const to = Math.min(offset + limit, state.total);
198
+ return html`
199
+ <sp-action-button
200
+ size="s"
201
+ quiet
202
+ title="Previous page"
203
+ ?disabled=${offset === 0 || state.loading}
204
+ @click=${() =>
205
+ void controller.setQuery({
206
+ ...state.query,
207
+ limit,
208
+ offset: Math.max(0, offset - limit),
209
+ })}
210
+ >
211
+ ‹ Prev
212
+ </sp-action-button>
213
+ <span class="jx-grid-count">${from}–${to}</span>
214
+ <sp-action-button
215
+ size="s"
216
+ quiet
217
+ title="Next page"
218
+ ?disabled=${offset + limit >= state.total || state.loading}
219
+ @click=${() => void controller.setQuery({ ...state.query, limit, offset: offset + limit })}
220
+ >
221
+ Next ›
222
+ </sp-action-button>
223
+ `;
224
+ }
225
+
226
+ function shellTpl(
227
+ controller: GridController,
228
+ getView: () => GridView | null,
229
+ onHost: (el: HTMLElement | undefined) => void,
230
+ ) {
231
+ return html`
232
+ <div class="jx-grid">
233
+ ${toolbarTpl(controller, getView)}
234
+ <div class="jx-grid-host" ${ref((el) => onHost(el as HTMLElement | undefined))}></div>
235
+ </div>
236
+ `;
237
+ }
238
+
239
+ /**
240
+ * Render the grid surface for a tab into #canvas-wrap. Re-entrant: same-tab calls while the panel
241
+ * is live are no-ops (the panel's own effect keeps it fresh).
242
+ *
243
+ * @param {HTMLElement} canvasWrap
244
+ * @param {Tab} tab
245
+ */
246
+ export function renderGridMode(canvasWrap: HTMLElement, tab: Tab) {
247
+ if (gridPanelMounted(tab)) {
248
+ return;
249
+ }
250
+ detachGridPanel();
251
+
252
+ let controller = getGridController(tab);
253
+ // CSV file tabs can reach grid mode through any open path (deep link, quick search, recents) —
254
+ // Provision their controller lazily so every path works, not just openCsvGridTab.
255
+ if (!controller && tab.documentPath?.toLowerCase().endsWith(".csv")) {
256
+ controller = createGridController(tab, createCsvFileSource(tab.documentPath));
257
+ void controller.load();
258
+ }
259
+ if (!controller) {
260
+ litRender(html`<div class="jx-grid-missing">This tab has no grid source.</div>`, canvasWrap);
261
+ return;
262
+ }
263
+
264
+ const scope = effectScope();
265
+ const panel: ActiveGridPanel = { scope, tabId: tab.id, view: null, wrap: canvasWrap };
266
+ active = panel;
267
+
268
+ let hostEl: HTMLElement | null = null;
269
+ const onHost = (el: HTMLElement | undefined) => {
270
+ hostEl = el ?? null;
271
+ };
272
+
273
+ scope.run(() => {
274
+ effect(() => {
275
+ if (active !== panel) {
276
+ return;
277
+ }
278
+ // Track everything the toolbar shows.
279
+ void controller.state.loading;
280
+ void controller.state.saving;
281
+ void controller.state.error;
282
+ void controller.state.total;
283
+ void controller.state.query.offset;
284
+ void controller.buffer.dirtyCount();
285
+
286
+ litRender(
287
+ shellTpl(controller, () => panel.view, onHost),
288
+ canvasWrap,
289
+ );
290
+
291
+ // Create the engine once the columns exist and the host div is in the DOM.
292
+ if (
293
+ !panel.view &&
294
+ !controller.state.loading &&
295
+ controller.state.columns.length > 0 &&
296
+ hostEl
297
+ ) {
298
+ panel.view = createGridView(hostEl, controller);
299
+ }
300
+ });
301
+ });
302
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Grid data-source contracts and grid-tab identity.
3
+ *
4
+ * A GridSource adapts one tabular backing store (a CSV file, a content collection, the pages tree,
5
+ * a connector table) to the grid editor. Sources load columns/rows and commit a batched set of
6
+ * pending edits; they never see the grid engine. Grid tabs over non-file sources are "virtual" —
7
+ * their tab id is a `grid://` URI rather than a document path.
8
+ */
9
+ import type { JsonSchema } from "../ui/schema-form";
10
+
11
+ /** A single cell value. JSON-shaped so batches serialize cleanly. */
12
+ export type GridCellValue = string | number | boolean | string[] | null | { $ref: string };
13
+
14
+ /** How a column edits/renders. Drives the cell editor/formatter mapping. */
15
+ export type GridColumnKind =
16
+ | "string"
17
+ | "text"
18
+ | "number"
19
+ | "boolean"
20
+ | "enum"
21
+ | "array"
22
+ | "image"
23
+ | "date"
24
+ | "reference"
25
+ | "readonly";
26
+
27
+ export interface GridColumn {
28
+ /** Row-object key. */
29
+ field: string;
30
+ title: string;
31
+ kind: GridColumnKind;
32
+ /** Original JSON-Schema property, when the column came from a schema. */
33
+ schema?: JsonSchema | undefined;
34
+ required?: boolean | undefined;
35
+ editable: boolean;
36
+ /** Row-identity column (frozen first, never editable). */
37
+ pk?: boolean | undefined;
38
+ /** Editable only on pending-insert rows (e.g. a new entry's path). */
39
+ insertOnly?: boolean | undefined;
40
+ /** Preferred initial width in px. */
41
+ widthHint?: number | undefined;
42
+ }
43
+
44
+ export interface GridRow {
45
+ /** Stable row id: file path, csv row id, or table pk value. */
46
+ key: string;
47
+ cells: Record<string, GridCellValue>;
48
+ /** Staleness token (raw file text, content hash, …); absent for server-authoritative rows. */
49
+ fingerprint?: string;
50
+ }
51
+
52
+ export interface GridQuery {
53
+ offset?: number;
54
+ limit?: number;
55
+ orderBy?: string;
56
+ dir?: "asc" | "desc";
57
+ }
58
+
59
+ export interface GridRowsResult {
60
+ rows: GridRow[];
61
+ total: number;
62
+ }
63
+
64
+ /** Pending edits, flattened for commit. Cells on pending-insert/delete rows are folded away. */
65
+ export interface GridEditBatch {
66
+ cells: { rowKey: string; field: string; value: GridCellValue; baseline: GridCellValue }[];
67
+ inserts: { tempKey: string; cells: Record<string, GridCellValue> }[];
68
+ deletes: { rowKey: string }[];
69
+ }
70
+
71
+ export interface CommitResult {
72
+ cells: {
73
+ rowKey: string;
74
+ field: string;
75
+ ok: boolean;
76
+ error?: string | undefined;
77
+ stale?: boolean | undefined;
78
+ }[];
79
+ inserts: {
80
+ tempKey: string;
81
+ ok: boolean;
82
+ newKey?: string | undefined;
83
+ error?: string | undefined;
84
+ }[];
85
+ deletes: {
86
+ rowKey: string;
87
+ ok: boolean;
88
+ error?: string | undefined;
89
+ stale?: boolean | undefined;
90
+ }[];
91
+ }
92
+
93
+ export interface GridSourceCapabilities {
94
+ insert: boolean;
95
+ delete: boolean;
96
+ /** Rows are paged by the backend — the grid shows a pager instead of loading everything. */
97
+ remotePaging: boolean;
98
+ /** Sorting must go through rows(query) rather than the local engine. */
99
+ remoteSort: boolean;
100
+ }
101
+
102
+ export interface GridSource {
103
+ /** Equals the tab id of the grid tab hosting it. */
104
+ id: string;
105
+ label: string;
106
+ capabilities: GridSourceCapabilities;
107
+ columns: () => Promise<GridColumn[]>;
108
+ rows: (query?: GridQuery) => Promise<GridRowsResult>;
109
+ commit: (batch: GridEditBatch) => Promise<CommitResult>;
110
+ /** Re-read the backing store; the next columns()/rows() serve fresh data. */
111
+ refresh?: (() => Promise<void>) | undefined;
112
+ /** Full-file text including pending edits — source-mode display for file-backed grids. */
113
+ serializeForSource?: ((batch: GridEditBatch) => Promise<string>) | undefined;
114
+ /** Project paths backing rows, for fs-watch staleness (path → rowKey; "*" = every row). */
115
+ backingPaths?: (() => Map<string, string>) | undefined;
116
+ dispose?: (() => void) | undefined;
117
+ }
118
+
119
+ // ─── Value comparison ─────────────────────────────────────────────────────────
120
+
121
+ /** Structural equality over GridCellValue (arrays element-wise, $ref by target). */
122
+ export function cellValuesEqual(a: GridCellValue, b: GridCellValue): boolean {
123
+ if (a === b) {
124
+ return true;
125
+ }
126
+ if (Array.isArray(a) && Array.isArray(b)) {
127
+ return a.length === b.length && a.every((v, i) => v === b[i]);
128
+ }
129
+ if (
130
+ a !== null &&
131
+ b !== null &&
132
+ typeof a === "object" &&
133
+ typeof b === "object" &&
134
+ !Array.isArray(a) &&
135
+ !Array.isArray(b)
136
+ ) {
137
+ return a.$ref === b.$ref;
138
+ }
139
+ return false;
140
+ }
141
+
142
+ // ─── Grid tab identity ────────────────────────────────────────────────────────
143
+
144
+ const GRID_SCHEME = "grid://";
145
+
146
+ export type GridTabRef =
147
+ | { kind: "pages" }
148
+ | { kind: "collection"; name: string }
149
+ | { kind: "data"; connection: string; table: string };
150
+
151
+ /** Build a virtual grid-tab id (`grid://…`). Segments are URI-encoded so `/` in names survives. */
152
+ export function makeGridTabId(ref: GridTabRef): string {
153
+ switch (ref.kind) {
154
+ case "pages": {
155
+ return `${GRID_SCHEME}pages`;
156
+ }
157
+ case "collection": {
158
+ return `${GRID_SCHEME}collection/${encodeURIComponent(ref.name)}`;
159
+ }
160
+ default: {
161
+ return `${GRID_SCHEME}data/${encodeURIComponent(ref.connection)}/${encodeURIComponent(ref.table)}`;
162
+ }
163
+ }
164
+ }
165
+
166
+ /** Whether a tab id is a virtual grid-tab id. */
167
+ export function isGridTabId(id: string): boolean {
168
+ return id.startsWith(GRID_SCHEME);
169
+ }
170
+
171
+ /** Parse a `grid://` tab id back into its source reference; null when not a grid id. */
172
+ export function parseGridTabId(id: string): GridTabRef | null {
173
+ if (!isGridTabId(id)) {
174
+ return null;
175
+ }
176
+ const parts = id.slice(GRID_SCHEME.length).split("/");
177
+ if (parts[0] === "pages" && parts.length === 1) {
178
+ return { kind: "pages" };
179
+ }
180
+ if (parts[0] === "collection" && parts.length === 2 && parts[1]) {
181
+ return { kind: "collection", name: decodeURIComponent(parts[1]) };
182
+ }
183
+ if (parts[0] === "data" && parts.length === 3 && parts[1] && parts[2]) {
184
+ return {
185
+ connection: decodeURIComponent(parts[1]),
186
+ kind: "data",
187
+ table: decodeURIComponent(parts[2]),
188
+ };
189
+ }
190
+ return null;
191
+ }
192
+
193
+ /** Short human label for a grid tab ("posts · grid", "users @ main · grid", "Pages · grid"). */
194
+ export function gridTabLabel(id: string): string | null {
195
+ const ref = parseGridTabId(id);
196
+ if (!ref) {
197
+ return null;
198
+ }
199
+ switch (ref.kind) {
200
+ case "pages": {
201
+ return "Pages · grid";
202
+ }
203
+ case "collection": {
204
+ return `${ref.name} · grid`;
205
+ }
206
+ default: {
207
+ return `${ref.table} @ ${ref.connection} · grid`;
208
+ }
209
+ }
210
+ }