@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.
- package/dist/iframe-entry.js +622 -49
- package/dist/iframe-entry.js.map +15 -12
- package/dist/studio.css +1333 -0
- package/dist/studio.js +103709 -81001
- package/dist/studio.js.map +201 -157
- package/package.json +47 -45
- package/src/account-status.ts +39 -0
- package/src/browse/browse.ts +10 -14
- package/src/canvas/canvas-live-render.ts +21 -2
- package/src/canvas/canvas-render.ts +35 -16
- package/src/canvas/canvas-utils.ts +118 -72
- package/src/canvas/iframe-entry.ts +20 -0
- package/src/canvas/iframe-eval.ts +155 -0
- package/src/canvas/iframe-host.ts +132 -6
- package/src/canvas/iframe-inline-edit.ts +107 -1
- package/src/canvas/iframe-protocol.ts +43 -3
- package/src/canvas/iframe-render.ts +18 -0
- package/src/collab/collab-session.ts +23 -8
- package/src/collab/collab-state.ts +6 -3
- package/src/component-props.ts +104 -0
- package/src/editor/context-menu.ts +1 -1
- package/src/editor/inline-edit-apply.ts +36 -1
- package/src/editor/inline-edit.ts +58 -1
- package/src/editor/repeater-scope.ts +8 -13
- package/src/editor/shortcuts.ts +41 -12
- package/src/files/file-ops.ts +14 -0
- package/src/files/files.ts +56 -1
- package/src/format/format-host.ts +63 -1
- package/src/grid/cell-editors.ts +288 -0
- package/src/grid/cell-popovers.ts +108 -0
- package/src/grid/csv-codec.ts +122 -0
- package/src/grid/edit-buffer.ts +490 -0
- package/src/grid/grid-controller.ts +417 -0
- package/src/grid/grid-layout.ts +83 -0
- package/src/grid/grid-open.ts +167 -0
- package/src/grid/grid-panel.ts +302 -0
- package/src/grid/grid-source.ts +210 -0
- package/src/grid/grid-view.ts +367 -0
- package/src/grid/schema-columns.ts +261 -0
- package/src/grid/sources/connector-source.ts +217 -0
- package/src/grid/sources/content-source.ts +542 -0
- package/src/grid/sources/csv-file-source.ts +247 -0
- package/src/grid/tabulator-tables.d.ts +120 -0
- package/src/new-project/add-repo-modal.ts +183 -0
- package/src/new-project/new-project-modal.ts +22 -3
- package/src/page-params.ts +34 -8
- package/src/panels/ai-chat/chat-markdown.ts +2 -2
- package/src/panels/ai-panel.ts +61 -4
- package/src/panels/block-action-bar.ts +8 -3
- package/src/panels/chat-panel.ts +98 -0
- package/src/panels/data-grid.ts +241 -0
- package/src/panels/editors.ts +43 -17
- package/src/panels/events-panel.ts +137 -44
- package/src/panels/formula-workspace.ts +379 -0
- package/src/panels/frontmatter-fields.ts +231 -0
- package/src/panels/frontmatter-panel.ts +132 -0
- package/src/panels/git-panel.ts +1 -1
- package/src/panels/head-panel.ts +11 -175
- package/src/panels/properties-panel.ts +154 -144
- package/src/panels/right-panel.ts +9 -47
- package/src/panels/signals-panel.ts +217 -460
- package/src/panels/statement-editor.ts +710 -0
- package/src/panels/statusbar.ts +1 -1
- package/src/panels/style-panel.ts +25 -1
- package/src/panels/stylebook-panel.ts +0 -2
- package/src/panels/tab-bar.ts +175 -6
- package/src/panels/tab-strip.ts +62 -6
- package/src/panels/toolbar.ts +37 -3
- package/src/panels/welcome-screen.ts +50 -0
- package/src/platform-errors.ts +30 -0
- package/src/platforms/cloud.ts +172 -89
- package/src/platforms/devserver.ts +172 -0
- package/src/services/ai-project-tools.ts +541 -0
- package/src/services/ai-session-store.ts +28 -0
- package/src/services/ai-system-prompt.ts +194 -24
- package/src/services/ai-tools.ts +88 -21
- package/src/services/automation.ts +16 -0
- package/src/services/context-resolver.ts +73 -0
- package/src/services/data-service.ts +155 -0
- package/src/services/document-assistant.ts +81 -11
- package/src/services/gated-registry.ts +72 -0
- package/src/services/live-preview.ts +0 -0
- package/src/services/monaco-setup.ts +75 -26
- package/src/services/preview-eval.ts +68 -0
- package/src/services/project-adoption.ts +31 -0
- package/src/settings/contributed-section.ts +406 -0
- package/src/settings/extension-sections.ts +145 -0
- package/src/settings/schema-field-ui.ts +4 -2
- package/src/settings/settings-modal.ts +101 -42
- package/src/site-context.ts +12 -1
- package/src/store.ts +4 -0
- package/src/studio.ts +107 -52
- package/src/tabs/patch-ops.ts +25 -0
- package/src/tabs/tab.ts +39 -1
- package/src/tabs/transact.ts +61 -14
- package/src/types.ts +132 -1
- package/src/ui/dynamic-slot.ts +272 -0
- package/src/ui/expression-editor.ts +423 -125
- package/src/ui/field-row.ts +5 -0
- package/src/ui/form-controls.ts +322 -0
- package/src/ui/formula-catalog.ts +557 -0
- package/src/ui/formula-chips.ts +216 -0
- package/src/ui/formula-palette.ts +211 -0
- package/src/ui/layers.ts +40 -0
- package/src/ui/media-picker.ts +1 -1
- package/src/ui/panel-resize.ts +15 -1
- package/src/ui/progress-modal.ts +2 -2
- package/src/ui/schema-form.ts +524 -0
- package/src/utils/preview-format.ts +26 -0
- package/src/utils/studio-utils.ts +4 -3
- package/src/view.ts +4 -4
- package/src/workspace/workspace.ts +14 -0
- package/src/settings/content-types-editor.ts +0 -599
|
@@ -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 & 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
|
+
}
|