@jxsuite/studio 2.4.3 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/README.md +5 -5
  2. package/dist/chunks/Overlay.dev-723bv0df.js +23 -0
  3. package/dist/chunks/cf-settings-45fgyx8y.js +21 -0
  4. package/dist/chunks/{components-z6cnkyy1.js → components-zmx10t62.js} +6 -5
  5. package/dist/chunks/content-source-t88vb2dr.js +49 -0
  6. package/dist/chunks/{convert-to-repeater-k10dvcc7.js → convert-to-repeater-dhw1nrz9.js} +27 -18
  7. package/dist/chunks/entry-commands-6z6bhxg0.js +46 -0
  8. package/dist/chunks/{extension-sections-0ada0xcw.js → extension-sections-3ydgjbdy.js} +32 -23
  9. package/dist/chunks/file-ops-rskmhxyq.js +48 -0
  10. package/dist/chunks/files-0cratsr0.js +92 -0
  11. package/dist/chunks/{format-host-wewba4nq.js → format-host-tgcmfs4p.js} +3 -3
  12. package/dist/chunks/fs-events-hrrg328g.js +21 -0
  13. package/dist/chunks/{git-panel-2dh1aw50.js → git-panel-0yywyk76.js} +21 -15
  14. package/dist/chunks/{jx-validate-jj9rnt4n.js → jx-validate-8q2g05x1.js} +6 -4
  15. package/dist/chunks/layers-panel-z3tqe5cd.js +67 -0
  16. package/dist/chunks/layers-q75tj2mf.js +45 -0
  17. package/dist/chunks/lit-html-j055mkh8.js +22 -0
  18. package/dist/chunks/monaco-buffer-dqw0xxpx.js +25 -0
  19. package/dist/chunks/{monaco-setup-a5wkmbgh.js → monaco-setup-rws7k4br.js} +4 -4
  20. package/dist/chunks/notify-yjm79n4s.js +30 -0
  21. package/dist/chunks/parse-24d0gay3.js +19 -0
  22. package/dist/chunks/platform-ba1j1gbh.js +16 -0
  23. package/dist/chunks/publish-panel-54jvhskq.js +28 -0
  24. package/dist/chunks/quick-search-cgfmjwq5.js +65 -0
  25. package/dist/chunks/references-3qx8fkyp.js +29 -0
  26. package/dist/chunks/right-panel-cqp8bc4v.js +46 -0
  27. package/dist/chunks/sp-overlay.dev-yztmtrs1.js +18 -0
  28. package/dist/chunks/sp-popover.dev-n2etmqdp.js +9 -0
  29. package/dist/chunks/sp-progress-circle.dev-hryhj0es.js +15 -0
  30. package/dist/chunks/sp-tooltip.dev-xcvpnqr0.js +15 -0
  31. package/dist/chunks/{sp-tray.dev-64v4pxw8.js → sp-tray.dev-tkbscm06.js} +5 -5
  32. package/dist/chunks/{studio-f5yyvqxv.js → studio-1hbqnt19.js} +37 -31
  33. package/dist/chunks/studio-2zkz8evn.js +3163 -0
  34. package/dist/chunks/{studio-0b6fs05q.js → studio-4pqqxbrt.js} +23 -15
  35. package/dist/chunks/{studio-kvfx9z2c.js → studio-4rzyrtjz.js} +70 -31
  36. package/dist/chunks/{studio-ef8t53vc.js → studio-5q69gfag.js} +60 -56
  37. package/dist/chunks/{studio-v2c5pmjj.js → studio-5rywrkxz.js} +565 -133
  38. package/dist/chunks/{studio-6kv3apa9.js → studio-68n67094.js} +25 -9
  39. package/dist/chunks/{studio-0rqqhrdy.js → studio-7nte2hj4.js} +2 -2
  40. package/dist/chunks/{studio-hyn3vhwr.js → studio-8swrjbny.js} +31 -18
  41. package/dist/chunks/studio-92ab2jqe.js +282 -0
  42. package/dist/chunks/{studio-sas7mt3k.js → studio-9ab8gqsm.js} +2 -2
  43. package/dist/chunks/{studio-28xc8ws4.js → studio-9c4k4vhv.js} +14 -10
  44. package/dist/chunks/studio-9n4mrmnb.js +184 -0
  45. package/dist/chunks/{studio-tr20m8g8.js → studio-9r6j5ev1.js} +37 -2
  46. package/dist/chunks/{studio-aweyr4v1.js → studio-ahjfdy6e.js} +9 -7
  47. package/dist/chunks/{studio-s519zc99.js → studio-bgxt93ga.js} +3 -3
  48. package/dist/chunks/{studio-26sv44br.js → studio-cb12yz9w.js} +8543 -8784
  49. package/dist/chunks/studio-cft9r4r7.js +119 -0
  50. package/dist/chunks/{studio-rqemfhz6.js → studio-eenfkt4e.js} +6 -6
  51. package/dist/chunks/studio-eesa1myv.js +498 -0
  52. package/dist/chunks/{studio-ay8wcr2b.js → studio-eqq1w638.js} +7 -9
  53. package/dist/chunks/{studio-ke244pam.js → studio-ex8zyp98.js} +51 -25
  54. package/dist/chunks/studio-gv8vvshc.js +118 -0
  55. package/dist/chunks/studio-hbtp0eh1.js +1315 -0
  56. package/dist/chunks/{studio-cz2j1c0g.js → studio-htsf0w6m.js} +75 -123
  57. package/dist/chunks/{studio-pyrw8h0w.js → studio-kjjsjak4.js} +528 -212
  58. package/dist/chunks/{studio-5x5774ak.js → studio-kryvsw6j.js} +3 -3
  59. package/dist/chunks/studio-kyh9avvv.js +28 -0
  60. package/dist/chunks/{studio-jnakhg87.js → studio-kyk2v92a.js} +14 -14
  61. package/dist/chunks/{studio-twsafr1e.js → studio-m3hwn7y1.js} +3 -3
  62. package/dist/chunks/{studio-tzx3xmvx.js → studio-mmv72a27.js} +8 -8
  63. package/dist/chunks/{studio-vhk5fmfv.js → studio-n8b9wqwy.js} +5 -5
  64. package/dist/chunks/{studio-cjm7fdjf.js → studio-p4ngc046.js} +8 -104
  65. package/dist/chunks/{studio-sccge2wf.js → studio-p8j86wag.js} +2 -2
  66. package/dist/chunks/{studio-v1va0vhd.js → studio-pdfjntbk.js} +75 -139
  67. package/dist/chunks/studio-q43a5y9h.js +394 -0
  68. package/dist/chunks/studio-q7v10cg2.js +106 -0
  69. package/dist/chunks/{studio-2w11grav.js → studio-qq6jym04.js} +3 -3
  70. package/dist/chunks/studio-r1r7afxx.js +1066 -0
  71. package/dist/chunks/{studio-3fk1ac9z.js → studio-rdx8jcxy.js} +4 -4
  72. package/dist/chunks/{studio-4mqb9a0j.js → studio-rn9ta2mz.js} +4 -4
  73. package/dist/chunks/studio-sf7zjgv1.js +91 -0
  74. package/dist/chunks/studio-t7qb9db8.js +665 -0
  75. package/dist/chunks/studio-tmc5emrw.js +8 -0
  76. package/dist/chunks/{studio-wdt9y92a.js → studio-txwkm6p9.js} +2 -2
  77. package/dist/chunks/{studio-dgj7gq44.js → studio-v38hmvx1.js} +7 -7
  78. package/dist/chunks/{studio-7ejemhej.js → studio-vadzgkrz.js} +6 -6
  79. package/dist/chunks/studio-wcy8trfx.js +263 -0
  80. package/dist/chunks/{studio-hb7c46jy.js → studio-wx5609xd.js} +21 -15
  81. package/dist/chunks/studio-xrapxwwc.js +1539 -0
  82. package/dist/chunks/{studio-ws6ef6js.js → studio-yxrkyv8a.js} +41 -29
  83. package/dist/chunks/{studio-xm3dbxvn.js → studio-zczps70q.js} +2 -2
  84. package/dist/chunks/workspace-xpres40v.js +89 -0
  85. package/dist/iframe-entry.js +450 -48
  86. package/dist/studio.js +2455 -1761
  87. package/package.json +16 -15
  88. package/src/browse/library-layouts.ts +3 -3
  89. package/src/browse/library-pane.ts +59 -5
  90. package/src/browse/library-preview.ts +19 -3
  91. package/src/canvas/asset-refs.ts +119 -0
  92. package/src/canvas/asset-resolve.ts +256 -0
  93. package/src/canvas/canvas-live-render.ts +23 -15
  94. package/src/canvas/canvas-render.ts +23 -1
  95. package/src/canvas/iframe-entry.ts +1 -0
  96. package/src/canvas/iframe-host.ts +4 -0
  97. package/src/canvas/iframe-patch.ts +47 -21
  98. package/src/canvas/iframe-protocol.ts +9 -0
  99. package/src/canvas/iframe-render.ts +50 -16
  100. package/src/commands/app-commands.ts +4 -0
  101. package/src/commands/command-args.ts +29 -0
  102. package/src/commands/context.ts +15 -1
  103. package/src/commands/defaults.ts +29 -1
  104. package/src/commands/levels.ts +16 -0
  105. package/src/commands/live-context.ts +3 -0
  106. package/src/content/collection-match.ts +267 -0
  107. package/src/content/entry-commands.ts +38 -29
  108. package/src/content/entry-fields.ts +6 -0
  109. package/src/editor/file-drop-action.ts +2 -1
  110. package/src/editor/shortcuts.ts +5 -0
  111. package/src/files/file-ops.ts +10 -42
  112. package/src/files/files.ts +487 -61
  113. package/src/files/fs-events.ts +9 -0
  114. package/src/files/gitignore.ts +432 -0
  115. package/src/files/media-paths.ts +37 -14
  116. package/src/files/media-upload.ts +47 -6
  117. package/src/files/serialize-document.ts +60 -0
  118. package/src/format/convert-file.ts +511 -0
  119. package/src/format/format-choices.ts +199 -0
  120. package/src/grid/grid-idle.ts +45 -0
  121. package/src/grid/grid-view.ts +35 -0
  122. package/src/grid/schema-columns.ts +2 -1
  123. package/src/grid/sources/content-source.ts +24 -3
  124. package/src/media/media-open.ts +58 -0
  125. package/src/media/media-pane.ts +310 -0
  126. package/src/new-project/import-tab.ts +333 -160
  127. package/src/new-project/new-project-modal.ts +27 -34
  128. package/src/packages/ensure-deps.ts +14 -7
  129. package/src/packages/jxsuite-update.ts +11 -11
  130. package/src/page-params.ts +10 -46
  131. package/src/panels/activity-bar.ts +45 -16
  132. package/src/panels/ai-chat/attached-context.ts +6 -1
  133. package/src/panels/ai-chat/chat-view.ts +255 -13
  134. package/src/panels/ai-chat/composer.ts +21 -80
  135. package/src/panels/ai-panel.ts +116 -2
  136. package/src/panels/frontmatter-fields.ts +6 -2
  137. package/src/panels/navigator-panels.ts +7 -6
  138. package/src/panels/properties-panel.ts +8 -2
  139. package/src/panels/seo-modal.ts +5 -6
  140. package/src/panels/settings-menu.ts +799 -0
  141. package/src/panels/signals-panel.ts +2 -1
  142. package/src/panels/toolbar.ts +135 -15
  143. package/src/platforms/cloud.ts +34 -3
  144. package/src/platforms/devserver.ts +72 -34
  145. package/src/preview/preview-overlay.ts +226 -0
  146. package/src/services/ai-ask.ts +247 -0
  147. package/src/services/ai-import-tools.ts +494 -0
  148. package/src/services/ai-models.ts +101 -11
  149. package/src/services/ai-project-tools.ts +20 -26
  150. package/src/services/ai-settings.ts +69 -71
  151. package/src/services/ai-system-prompt.ts +88 -2
  152. package/src/services/ai-turn-signal.ts +70 -0
  153. package/src/services/cf-settings.ts +19 -37
  154. package/src/services/connection.ts +74 -0
  155. package/src/services/context-manager.ts +89 -0
  156. package/src/services/document-assistant.ts +49 -7
  157. package/src/services/idle.ts +6 -1
  158. package/src/services/import-client.ts +33 -6
  159. package/src/services/import-run.ts +158 -0
  160. package/src/services/import-seed.ts +120 -0
  161. package/src/services/jx-validate.ts +58 -6
  162. package/src/services/project-adoption.ts +71 -0
  163. package/src/services/references.ts +0 -0
  164. package/src/services/settings/definitions.ts +181 -0
  165. package/src/services/settings/kernel.ts +335 -0
  166. package/src/services/settings/write-queue.ts +116 -0
  167. package/src/services/tool-executor.ts +34 -2
  168. package/src/settings/contexts-section.ts +45 -3
  169. package/src/settings/dependencies-editor.ts +43 -23
  170. package/src/settings/general-settings.ts +30 -12
  171. package/src/settings/preferences-accounts.ts +6 -19
  172. package/src/settings/preferences-dialog.ts +18 -41
  173. package/src/settings/preferences-keymap.ts +8 -18
  174. package/src/settings/preferences-sections.ts +57 -0
  175. package/src/settings/schema-field-ui.ts +2 -2
  176. package/src/settings/settings-document.ts +65 -9
  177. package/src/shell.ts +6 -14
  178. package/src/studio.ts +65 -7
  179. package/src/tabs/transact.ts +42 -19
  180. package/src/types.ts +164 -8
  181. package/src/ui/ai-credentials-form.ts +52 -27
  182. package/src/ui/ai-model-picker.ts +162 -0
  183. package/src/ui/choice-field.ts +72 -0
  184. package/src/ui/layers.ts +83 -5
  185. package/src/ui/media-picker.ts +6 -11
  186. package/src/utils/studio-utils.ts +43 -31
  187. package/styles/forced-colors.css +7 -0
  188. package/styles/overlays.css +256 -0
  189. package/styles/panels.css +13 -1
  190. package/styles/shell.css +111 -0
  191. package/dist/chunks/Overlay.dev-d2ajy6sj.js +0 -23
  192. package/dist/chunks/cf-settings-g5fbf2zp.js +0 -17
  193. package/dist/chunks/content-source-k9724vcv.js +0 -39
  194. package/dist/chunks/files-yew27sn9.js +0 -69
  195. package/dist/chunks/layers-panel-qnsghwwd.js +0 -59
  196. package/dist/chunks/publish-panel-a0pdft5w.js +0 -21
  197. package/dist/chunks/quick-search-9avyc4pc.js +0 -54
  198. package/dist/chunks/right-panel-kcnhtc5j.js +0 -38
  199. package/dist/chunks/sp-overlay.dev-s18hymn9.js +0 -18
  200. package/dist/chunks/sp-popover.dev-dj9x7e7a.js +0 -9
  201. package/dist/chunks/sp-progress-circle.dev-0zxcyz3r.js +0 -15
  202. package/dist/chunks/sp-tooltip.dev-5c6d1kjz.js +0 -15
  203. package/dist/chunks/studio-a7pzveq1.js +0 -110
  204. package/dist/chunks/studio-c31yb4t6.js +0 -55
  205. package/dist/chunks/studio-ra7ar50f.js +0 -6682
  206. package/src/canvas/content-assets.ts +0 -263
  207. package/src/canvas/site-style-css.ts +0 -121
  208. package/src/services/settings-store.ts +0 -99
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Format-choices — the two derived predicates behind "which format may I create?" and "which format
3
+ * may I convert to?".
4
+ *
5
+ * Both are computed from what a format class DECLARES — `capabilities.parse`,
6
+ * `capabilities.serialize`, `format.documentKinds` — so a third format extension appears in the New
7
+ * File picker and in Convert To with no edit to this file, which is the same contract
8
+ * `format/format-host.ts` already keeps for opening and saving.
9
+ *
10
+ * Pure and synchronous over the loaded registry and `projectState`, because the Files tree's
11
+ * context menu is built during a render and cannot await anything.
12
+ */
13
+
14
+ import { collectionForFile } from "../content/collection-match";
15
+ import { documentExtensions, formatByExtension, formatForPath, getFormats } from "./format-host";
16
+ import { projectState } from "../store";
17
+
18
+ /** One row of a format picker: the extension it writes, and the name the reader sees. */
19
+ export interface FormatChoiceRow {
20
+ ext: string;
21
+ label: string;
22
+ }
23
+
24
+ /**
25
+ * The document kinds that mean "this format's `parse` returns a Jx document".
26
+ *
27
+ * There is no separate declaration for the SHAPE `parse` returns, and none is needed: the compiler
28
+ * builds its page and component globs from `documentExtensions("page")` /
29
+ * `documentExtensions("component")` and casts every `parse` result to `JxDocument`
30
+ * (`packages/compiler/src/site/pages-discovery.ts`). So declaring either kind IS the claim, already
31
+ * relied upon by the build. A `content`-only format — Csv, whose `parse` returns
32
+ * `ContentLoaderEntry[]` rows — makes no such claim and is excluded by the same test.
33
+ */
34
+ const DOCUMENT_KINDS = ["page", "component"] as const;
35
+
36
+ /**
37
+ * Where a Jx document lives.
38
+ *
39
+ * Both keys are conventions the build hard-codes rather than reads (`site-build.ts` resolves
40
+ * `pages` against the project root), so a convert may lean on them. It is deliberately
41
+ * conservative: a component kept in `shared/` is not offered a conversion, which is a smaller wrong
42
+ * than offering one for `package.json`, `tsconfig.json` or a `nav.json` that no format and no
43
+ * schema claims.
44
+ */
45
+ const DOCUMENT_ROOTS = { components: "component", pages: "page" } as const;
46
+
47
+ /** Normalise a project-relative path for prefix comparison. */
48
+ function normalize(path: string): string {
49
+ return path.replaceAll("\\", "/").replace(/^\.\//, "");
50
+ }
51
+
52
+ /** Which document root a path sits under, or null. */
53
+ function documentRootOf(path: string): "page" | "component" | null {
54
+ const lower = normalize(path).toLowerCase();
55
+ for (const [dir, kind] of Object.entries(DOCUMENT_ROOTS)) {
56
+ if (lower.startsWith(`${dir}/`)) {
57
+ return kind;
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+
63
+ /** Whether a format declares at least one of the document kinds. */
64
+ function declaresDocumentKind(kinds: readonly string[]): boolean {
65
+ return DOCUMENT_KINDS.some((kind) => kinds.includes(kind));
66
+ }
67
+
68
+ /**
69
+ * Whether this path is (or is used as) a layout.
70
+ *
71
+ * Layouts are JSON at BOTH readers — `packages/compiler/src/site/layout-resolver.ts` `JSON.parse`s
72
+ * and throws, `packages/studio/src/site-context.ts` `JSON.parse`s and silently returns null — and
73
+ * there is no `"layout"` document kind for a format to declare, so this clause is hand-written and
74
+ * cannot be derived. `layouts/` is excluded by {@link DOCUMENT_ROOTS} already; what remains is a
75
+ * project whose `layout` points somewhere inside a document root.
76
+ */
77
+ function isLayoutPath(path: string): boolean {
78
+ const declared = projectState?.projectConfig?.layout;
79
+ if (typeof declared !== "string") {
80
+ return false;
81
+ }
82
+ return normalize(declared).toLowerCase() === normalize(path).toLowerCase();
83
+ }
84
+
85
+ /**
86
+ * The rows the New File format picker offers, `.json` first.
87
+ *
88
+ * A format qualifies only when SOME registered class can both `parse` and `serialize` its
89
+ * extension. Both halves are load-bearing and neither is cosmetic:
90
+ *
91
+ * - Without `parse`, opening the file that was just created throws `noFormatError`
92
+ * (`files/file-ops.ts`'s `parseSourceForPath`);
93
+ * - Without `serialize`, the first save falls through to `defaultContentFormat()` and writes ANOTHER
94
+ * format's bytes into the file (`files/file-ops.ts`'s `serializeDocument`).
95
+ *
96
+ * Looked up per `(extension, capability)` rather than per format row because the registry
97
+ * deliberately legalises a split claim — one class parsing an extension, another serializing it.
98
+ *
99
+ * @param kind - Restrict to formats declaring this document kind (the Library's New ▸ Page).
100
+ * @returns The picker's format rows, without the caller's own sentinel rows.
101
+ */
102
+ export function creationFormats(kind?: "page" | "component" | "content"): FormatChoiceRow[] {
103
+ const rows: FormatChoiceRow[] = [{ ext: ".json", label: "JSON (.json)" }];
104
+ for (const ext of documentExtensions(kind)) {
105
+ const reader = formatByExtension(ext, "parse");
106
+ if (reader && formatByExtension(ext, "serialize")) {
107
+ rows.push({ ext, label: `${reader.name} (${ext})` });
108
+ }
109
+ }
110
+ return rows;
111
+ }
112
+
113
+ /**
114
+ * Every extension the studio recognises as a document — `.json` plus everything any registered
115
+ * format claims, whether or not it is creatable.
116
+ *
117
+ * This is the set a name is tested against, not {@link creationFormats}: a typed `notes.csv` must be
118
+ * recognised as naming a format the picker did not offer, rather than treated as an unremarkable
119
+ * suffix.
120
+ */
121
+ export function knownDocumentExtensions(): Set<string> {
122
+ return new Set([".json", ...getFormats().flatMap((format) => format.extensions)]);
123
+ }
124
+
125
+ /**
126
+ * The formats this file may be converted INTO, or an empty list when it may not be converted at
127
+ * all.
128
+ *
129
+ * Empty is the answer for, and only for:
130
+ *
131
+ * - A file outside `pages/` and `components/` — including every `.json` that is configuration rather
132
+ * than a document, and every `layouts/` file;
133
+ * - The project's declared `layout`, wherever it lives;
134
+ * - Anything inside a content collection, in EITHER direction. Converting an entry drops it out of
135
+ * its collection's discovery glob; converting a co-located file INTO the collection's format
136
+ * enlists it as an entry nobody seeded. Source and target share a directory, so one test covers
137
+ * both;
138
+ * - A source whose format cannot `parse`, or declares no document kind (Csv);
139
+ * - A source for which no OTHER format can `serialize` a document.
140
+ *
141
+ * @param path - Project-relative file path.
142
+ * @returns The target rows, or `[]`.
143
+ */
144
+ export function convertTargets(path: string | null | undefined): FormatChoiceRow[] {
145
+ if (path === null || path === undefined || path === "") {
146
+ return [];
147
+ }
148
+ const root = documentRootOf(path);
149
+ if (root === null || isLayoutPath(path) || collectionForFile(path)) {
150
+ return [];
151
+ }
152
+
153
+ const source = formatForPath(path);
154
+ const readable = source
155
+ ? Boolean(source.capabilities.parse) && declaresDocumentKind(source.documentKinds)
156
+ : normalize(path).toLowerCase().endsWith(".json");
157
+ if (!readable) {
158
+ return [];
159
+ }
160
+
161
+ const rows: FormatChoiceRow[] = [];
162
+ if (source) {
163
+ rows.push({ ext: ".json", label: "JSON (.json)" });
164
+ }
165
+ for (const ext of documentExtensions()) {
166
+ const writer = formatByExtension(ext, "serialize");
167
+ if (
168
+ !writer ||
169
+ writer.name === source?.name ||
170
+ !declaresDocumentKind(writer.documentKinds) ||
171
+ !writer.documentKinds.includes(root)
172
+ ) {
173
+ continue;
174
+ }
175
+ rows.push({ ext, label: `${writer.name} (${ext})` });
176
+ }
177
+ return rows;
178
+ }
179
+
180
+ /**
181
+ * Every extension that is a conversion target SOMEWHERE in this project — the enum behind
182
+ * `file.convertFormat`'s `format` argument.
183
+ *
184
+ * Necessarily broader than {@link convertTargets}, which is answered per file: a command argument's
185
+ * declared values cannot depend on another argument. The per-file rule still decides, and
186
+ * `convertFile` re-checks it, so this list is what may be ASKED for and not what will be accepted.
187
+ */
188
+ export function convertTargetExtensions(): string[] {
189
+ const exts = new Set<string>([".json"]);
190
+ for (const format of getFormats()) {
191
+ if (!format.capabilities.serialize || !declaresDocumentKind(format.documentKinds)) {
192
+ continue;
193
+ }
194
+ for (const ext of format.extensions) {
195
+ exts.add(ext);
196
+ }
197
+ }
198
+ return [...exts].toSorted();
199
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Grid-idle.ts — the grid's quiescence registry, and nothing else.
3
+ *
4
+ * A separate module from `grid-view.ts` for one reason: `services/idle.ts` must be able to ASK
5
+ * whether any grid is mid-flight without pulling Tabulator into its import graph. It read the
6
+ * probes from `grid-view.ts` first, and `grid-view.ts` imports `tabulator-tables` at module load —
7
+ * so every test that transitively reached `idle.ts` (which is most of them, through the shell)
8
+ * tried to evaluate the Tabulator ESM bundle and died with `Export named 'ClipboardModule' not
9
+ * found` unless it had thought to mock a module it never uses.
10
+ *
11
+ * So the dependency points the other way: this file imports nothing, `idle.ts` reads it, and
12
+ * `grid-view.ts` writes to it.
13
+ */
14
+
15
+ /** Answers `null` when its grid is settled, or a human-readable reason when it is not. */
16
+ export type GridProbe = () => string | null;
17
+
18
+ const liveGrids = new Set<GridProbe>();
19
+
20
+ /** Register a live grid. The returned function deregisters it — call it from `destroy()`. */
21
+ export function registerGridProbe(probe: GridProbe): () => void {
22
+ liveGrids.add(probe);
23
+ return () => liveGrids.delete(probe);
24
+ }
25
+
26
+ /**
27
+ * Grids still building, or still laying out their selection range.
28
+ *
29
+ * A probe must never throw: it runs inside the idle predicate, and an exception there would be
30
+ * indistinguishable from a subsystem that never settles.
31
+ */
32
+ export function gridIdleBlockers(): readonly string[] {
33
+ const blockers: string[] = [];
34
+ for (const probe of liveGrids) {
35
+ try {
36
+ const reason = probe();
37
+ if (reason) {
38
+ blockers.push(reason);
39
+ }
40
+ } catch {
41
+ // A table torn down between registration and probe is settled by definition.
42
+ }
43
+ }
44
+ return blockers;
45
+ }
@@ -40,6 +40,7 @@ import type {
40
40
  } from "tabulator-tables";
41
41
  import type { GridCellValue, GridColumn } from "./grid-source";
42
42
  import type { GridController } from "./grid-controller";
43
+ import { registerGridProbe } from "./grid-idle";
43
44
 
44
45
  export interface GridView {
45
46
  /** Re-pull controller.effectiveRows() into the table (buffer → table sync). */
@@ -222,6 +223,37 @@ export function createGridView(host: HTMLElement, controller: GridController): G
222
223
  }
223
224
  });
224
225
 
226
+ /*
227
+ * Settled means BUILT AND LAID OUT, not merely constructed. `selectableRange: 1` gives a
228
+ * non-empty table an initial range, and that range's outline is a rendered element — so until it
229
+ * exists the picture is still changing. An empty table is exempt, because it never gets one and
230
+ * waiting for it would be waiting forever.
231
+ *
232
+ * The MODEL is not the picture, and that distinction is the whole bug this second clause fixes.
233
+ * `getRanges()` answers from Tabulator's range model, which is populated when the range is
234
+ * created; the outline is a `.tabulator-range` div the SelectRange module paints into
235
+ * `.tabulator-range-overlay` on a later frame. Between the two, the grid reports itself settled
236
+ * and the capture lands on a table whose selected cell has no outline — so `data-grid.png`
237
+ * alternated between an outlined "Elena M." and a bare one, 16 rewrites in ten days at a steady
238
+ * 0.089 %. Asking the DOM for a laid-out box closes the window that the model check leaves open.
239
+ */
240
+ const probe = (): string | null => {
241
+ if (!built) {
242
+ return `grid[${gridId}]: building`;
243
+ }
244
+ if (controller.effectiveRows().length > 0) {
245
+ if (table.getRanges().length === 0) {
246
+ return `grid[${gridId}]: selection range not laid out`;
247
+ }
248
+ const outline = host.querySelector(".tabulator-range-overlay .tabulator-range");
249
+ if (!outline || rectOf(outline).width === 0) {
250
+ return `grid[${gridId}]: selection range outline not painted`;
251
+ }
252
+ }
253
+ return null;
254
+ };
255
+ const unregisterProbe = registerGridProbe(probe);
256
+
225
257
  table.on("cellEdited", (cell: CellComponent) => {
226
258
  if (suppress) {
227
259
  return;
@@ -307,6 +339,9 @@ export function createGridView(host: HTMLElement, controller: GridController): G
307
339
 
308
340
  return {
309
341
  destroy() {
342
+ // Before `table.destroy()`: a probe left registered against a torn-down table would be asking
343
+ // Tabulator questions it can no longer answer.
344
+ unregisterProbe();
310
345
  controller.bindView(null);
311
346
  table.destroy();
312
347
  },
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import type { GridCellValue, GridColumn, GridColumnKind } from "./grid-source";
12
12
  import type { JsonSchema } from "../ui/schema-form";
13
+ import { isMediaFormat } from "../utils/studio-utils";
13
14
 
14
15
  /** JSON-Schema property as it appears in content-type schemas (superset of ui JsonSchema). */
15
16
  interface PropSchema extends JsonSchema {
@@ -44,7 +45,7 @@ export function kindForProp(prop: PropSchema): GridColumnKind {
44
45
  return "enum";
45
46
  }
46
47
  const format = prop.type === "array" ? undefined : prop.format;
47
- if (format === "image") {
48
+ if (isMediaFormat(format)) {
48
49
  return "image";
49
50
  }
50
51
  if (format === "date" || format === "date-time") {
@@ -269,20 +269,41 @@ function buildEntryFileSource(opts: EntryFileSourceOptions): GridSource {
269
269
  const files = await opts.list();
270
270
  entries.clear();
271
271
  columnsCache = null;
272
- await mapLimit(files, 8, async (path) => {
272
+ /*
273
+ * Read concurrently, INSERT IN ORDER.
274
+ *
275
+ * `entries` is a Map and the grid renders it in insertion order, so setting it from inside
276
+ * the worker made row order the order the reads happened to FINISH in — which is a race
277
+ * between two `platform.readFile` calls, not a property of the collection. The two posts in
278
+ * the `first-collection` fixture swapped places between runs, and that alone rewrote
279
+ * `blog-grid.png` in 3 of the 21 images the screenshot lane pushed over 24 commits.
280
+ *
281
+ * `mapLimit` already documents that it preserves item order; the fix is to USE its result
282
+ * rather than to mutate shared state from inside it. The concurrency is worth keeping — a
283
+ * collection is a directory of files and reading it eight at a time is why the grid opens
284
+ * quickly.
285
+ */
286
+ const loaded = await mapLimit(files, 8, async (path): Promise<EntryRecord | null> => {
273
287
  try {
274
288
  const text = await platform.readFile(path);
275
289
  const parsed = await parseSourceForPath(path, text);
276
- entries.set(path, {
290
+ return {
277
291
  document: parsed.document,
278
292
  formatName: parsed.format.name,
279
293
  frontmatter: parsed.frontmatter ?? {},
280
294
  rawText: text,
281
- });
295
+ };
282
296
  } catch {
283
297
  // Unparseable entry — leave it out rather than failing the whole grid.
298
+ return null;
284
299
  }
285
300
  });
301
+ for (const [index, path] of files.entries()) {
302
+ const record = loaded[index];
303
+ if (record) {
304
+ entries.set(path, record);
305
+ }
306
+ }
286
307
  })();
287
308
  return loadPromise;
288
309
  };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Open a media file in the viewer.
3
+ *
4
+ * The shape of `grid/grid-open.ts`'s `openCsvGridTab`, and for the same reason: a real file tab
5
+ * keyed by its path, with a stub document, because the tab model wants A DOCUMENT and this file is
6
+ * not one. The viewer never reads it — it reads `tab.documentPath` and shows the bytes at that path
7
+ * through an `<img>`, a `<video>` or a font specimen.
8
+ *
9
+ * `.svg` gets a `source` mode beside the viewer: it is the one media format that is also text, and
10
+ * hand-editing one is an ordinary thing to want.
11
+ *
12
+ * The modes are set HERE rather than in `tabs/tab.ts`'s `inferModes`, which is the same division
13
+ * CSV already lives under: `inferModes` answers for DOCUMENTS, from the format registry, and an
14
+ * opener answers for the files that are not documents. Every media tab is created through this
15
+ * function — `openFileInTab` routes to it, and session restore goes through `openFileInTab` — so
16
+ * there is no path by which one is built without them.
17
+ *
18
+ * @docs studio/projects/media
19
+ */
20
+
21
+ import { activateTab, openTab, workspace } from "../workspace/workspace";
22
+ import { extensionOf, isMediaFile } from "../files/media-upload";
23
+ import { MEDIA_MODE } from "./media-pane";
24
+ import type { Tab } from "../tabs/tab";
25
+
26
+ /** Placeholder document for a media tab — the viewer never reads it, and there is nothing to save. */
27
+ const MEDIA_STUB_DOCUMENT = { children: [], tagName: "div" };
28
+
29
+ /** Whether this path opens in the media viewer rather than through a document parse. */
30
+ export function isViewableMedia(path: string): boolean {
31
+ return isMediaFile(path);
32
+ }
33
+
34
+ /** The modes a media tab offers. SVG is also text, so it keeps a source alternate. */
35
+ export function mediaTabModes(path: string): string[] {
36
+ return extensionOf(path) === ".svg" ? [MEDIA_MODE, "source"] : [MEDIA_MODE];
37
+ }
38
+
39
+ /**
40
+ * Open (or activate) a media file's viewer tab.
41
+ *
42
+ * @param {string} path - Project-relative path of the file
43
+ * @returns {Tab}
44
+ */
45
+ export function openMediaTab(path: string): Tab {
46
+ const existing = workspace.tabs.get(path);
47
+ if (existing) {
48
+ activateTab(path);
49
+ return existing;
50
+ }
51
+ return openTab({
52
+ capabilities: { modes: mediaTabModes(path) },
53
+ document: structuredClone(MEDIA_STUB_DOCUMENT),
54
+ documentPath: path,
55
+ id: path,
56
+ sourceFormat: null,
57
+ });
58
+ }