@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
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { getPlatform } from "../platform";
12
+ import { reloadIgnoreCache, touchesGitignore } from "./gitignore";
12
13
  import { invalidateUsages } from "../services/references";
13
14
  import { projectState } from "../store";
14
15
  import type { DirEntry, FsEvent } from "../types";
@@ -174,6 +175,14 @@ export function startFsSync(ctx: FsSyncContext): () => void {
174
175
  // Gone.
175
176
  invalidateUsages();
176
177
  ctx.invalidateDerivedCaches?.();
178
+ /* A `.gitignore` governs the whole tree beneath it, so it is handled HERE rather than in the
179
+ batch below: the echo filter drops the events Studio caused, and an author who just edited
180
+ their own `.gitignore` in Studio is precisely the one waiting to see the tree change. It also
181
+ never reaches `applyFsEvents` — a dotfile is in no listing, so no directory reads as changed
182
+ and no repaint would follow. Hence the explicit one, once the rules are back. */
183
+ if (touchesGitignore(events.map((event) => event.path))) {
184
+ void reloadIgnoreCache().then(() => ctx.renderLeftPanel());
185
+ }
177
186
  pending.push(...events);
178
187
  if (timer) {
179
188
  clearTimeout(timer);
@@ -0,0 +1,432 @@
1
+ /**
2
+ * Gitignore matching for the Files sidebar.
3
+ *
4
+ * A project root is a working directory, and a working directory holds two populations: the files
5
+ * the author writes, and the files a tool wrote for them — `node_modules`, `dist`, `coverage`,
6
+ * `.next`. The sidebar listed both, so a project of forty documents opened as a tree of forty
7
+ * thousand rows. The author already told us which is which, in the one file every project of this
8
+ * shape carries: `.gitignore`.
9
+ *
10
+ * **This lives in Studio, not behind the PAL, on purpose.** `listDirectory` has three
11
+ * implementations — the dev server's route, the desktop session's `readdir`, and the cloud backend,
12
+ * which is not in this repository at all. Filtering there is the same rule written three times, and
13
+ * a rule written three times is a rule that disagrees with itself; the cloud copy could not even be
14
+ * changed in the same pull request. Filtering here is written once and is true on every host the
15
+ * moment it ships. It costs one `readFile` per directory (cached, negatives included), which is the
16
+ * same round trip the listing itself already makes.
17
+ *
18
+ * **The tree cache stays faithful.** {@link isIgnoredEntry} is consulted where rows are BUILT, not
19
+ * where entries are stored, so `projectState.dirs` keeps mirroring the filesystem and the show/hide
20
+ * toggle is a repaint rather than a refetch. It also means `applyFsEvents` needs to know nothing
21
+ * about any of this: a file arriving into an ignored directory lands in the cache and is simply not
22
+ * drawn.
23
+ *
24
+ * The matcher implements `gitignore(5)` — comments, negation, trailing-space and `#`/`!` escapes,
25
+ * directory-only patterns, anchoring, `**`, character classes, and last-match-wins across layers
26
+ * ordered shallow-to-deep. What it deliberately does NOT read is `.git/info/exclude` or
27
+ * `core.excludesFile`: both live outside the project, neither is reachable through the PAL's
28
+ * project-rooted `readFile`, and a rule the author cannot see in their own repository is a poor
29
+ * explanation for a missing row.
30
+ *
31
+ * @docs studio/interface
32
+ */
33
+
34
+ import { getPlatform } from "../platform";
35
+
36
+ // ─── The rule ─────────────────────────────────────────────────────────────────
37
+
38
+ /** One `.gitignore` line, compiled. */
39
+ export interface IgnoreRule {
40
+ /** The line as written. Kept so a hidden row can be explained by the rule that hid it. */
41
+ source: string;
42
+ /** `!pattern` — re-includes what an earlier rule excluded. */
43
+ negated: boolean;
44
+ /** `pattern/` — matches a directory and never a file. */
45
+ dirOnly: boolean;
46
+ /** Matches a path relative to the directory the rule's own `.gitignore` sits in. */
47
+ re: RegExp;
48
+ }
49
+
50
+ /** The rules of one `.gitignore`, with the directory they are relative to. */
51
+ export interface IgnoreLayer {
52
+ /** Project-relative, forward-slashed, no trailing slash; `""` is the project root. */
53
+ base: string;
54
+ rules: IgnoreRule[];
55
+ }
56
+
57
+ // ─── Parsing ──────────────────────────────────────────────────────────────────
58
+
59
+ /** Characters that mean something to a RegExp and nothing to a glob. */
60
+ const RE_SPECIAL = /[.*+?^${}()|[\]\\]/;
61
+
62
+ /** One glob character as a RegExp literal. */
63
+ function literal(ch: string): string {
64
+ return RE_SPECIAL.test(ch) ? `\\${ch}` : ch;
65
+ }
66
+
67
+ /**
68
+ * Drop trailing spaces, keeping any the author escaped.
69
+ *
70
+ * A space survives when the run of backslashes immediately before it is odd — the same rule that
71
+ * decides whether the backslash escaped the space or another backslash.
72
+ */
73
+ function stripTrailingSpaces(line: string): string {
74
+ let end = line.length;
75
+ while (end > 0 && line[end - 1] === " ") {
76
+ let backslashes = 0;
77
+ let i = end - 2;
78
+ while (i >= 0 && line[i] === "\\") {
79
+ backslashes += 1;
80
+ i -= 1;
81
+ }
82
+ if (backslashes % 2 === 1) {
83
+ break;
84
+ }
85
+ end -= 1;
86
+ }
87
+ return line.slice(0, end);
88
+ }
89
+
90
+ /**
91
+ * The index of the `]` closing the class opened at `start`, or -1 when the line has none.
92
+ *
93
+ * A `]` in the first position (after an optional `!`) is a literal member rather than the close,
94
+ * which is POSIX's rule and git's.
95
+ */
96
+ function charClassEnd(pattern: string, start: number): number {
97
+ let i = start + 1;
98
+ if (pattern[i] === "!") {
99
+ i += 1;
100
+ }
101
+ if (pattern[i] === "]") {
102
+ i += 1;
103
+ }
104
+ while (i < pattern.length) {
105
+ if (pattern[i] === "\\") {
106
+ i += 2;
107
+ continue;
108
+ }
109
+ if (pattern[i] === "]") {
110
+ return i;
111
+ }
112
+ i += 1;
113
+ }
114
+ return -1;
115
+ }
116
+
117
+ /**
118
+ * Compile a gitignore glob body to a RegExp source.
119
+ *
120
+ * `*` and `?` stop at a separator; `**` crosses them, but only where git says it does — as a whole
121
+ * path segment. `a**b` is git's "two stars that are not a segment", and it behaves as one star.
122
+ */
123
+ function globToRegExpSource(pattern: string): string {
124
+ let out = "";
125
+ let i = 0;
126
+ while (i < pattern.length) {
127
+ const ch = pattern[i]!;
128
+ if (ch === "\\") {
129
+ const next = pattern[i + 1];
130
+ if (next === undefined) {
131
+ // A trailing backslash escapes nothing; match it literally.
132
+ out += String.raw`\\`;
133
+ i += 1;
134
+ } else {
135
+ out += literal(next);
136
+ i += 2;
137
+ }
138
+ continue;
139
+ }
140
+ if (ch === "*") {
141
+ let stars = 0;
142
+ while (pattern[i] === "*") {
143
+ stars += 1;
144
+ i += 1;
145
+ }
146
+ /* `out` ends in `/` only where a separator was just emitted — every compiled form above ends
147
+ in `*`, `)` or an escaped literal — so this is exactly "at the start of a segment". */
148
+ const atSegmentStart = out === "" || out.endsWith("/");
149
+ if (stars >= 2 && atSegmentStart && pattern[i] === "/") {
150
+ // `**/` — zero or more whole directories.
151
+ out += "(?:[^/]+/)*";
152
+ i += 1;
153
+ } else if (stars >= 2 && atSegmentStart && i >= pattern.length) {
154
+ // A trailing `/**` — everything inside, at any depth.
155
+ out += ".*";
156
+ } else {
157
+ out += "[^/]*";
158
+ }
159
+ continue;
160
+ }
161
+ if (ch === "?") {
162
+ out += "[^/]";
163
+ i += 1;
164
+ continue;
165
+ }
166
+ if (ch === "[") {
167
+ const end = charClassEnd(pattern, i);
168
+ if (end === -1) {
169
+ out += String.raw`\[`;
170
+ i += 1;
171
+ continue;
172
+ }
173
+ const raw = pattern.slice(i + 1, end);
174
+ const negatedClass = raw.startsWith("!");
175
+ const members = negatedClass ? raw.slice(1) : raw;
176
+ /* A `]` in the first position is a MEMBER to git and POSIX, and {@link charClassEnd} finds
177
+ the real close accordingly — but JavaScript reads `[]` as an empty class that matches
178
+ nothing, and `[^]` as "any character". Emitting the member verbatim therefore inverts the
179
+ rule instead of translating it, so the leading `]` is escaped on the way out. */
180
+ out += `[${negatedClass ? "^" : ""}${members.startsWith("]") ? String.raw`\]${members.slice(1)}` : members}]`;
181
+ i = end + 1;
182
+ continue;
183
+ }
184
+ out += literal(ch);
185
+ i += 1;
186
+ }
187
+ return out;
188
+ }
189
+
190
+ /** Compile one line, or `null` when it is blank, a comment, or names nothing. */
191
+ function parseLine(raw: string): IgnoreRule | null {
192
+ let line = stripTrailingSpaces(raw);
193
+ if (line === "" || line.startsWith("#")) {
194
+ return null;
195
+ }
196
+ let negated = false;
197
+ if (line.startsWith("!")) {
198
+ negated = true;
199
+ line = line.slice(1);
200
+ }
201
+ /* `\#` and `\!` reach the compiler with their backslash intact and come out as literals, so the
202
+ two checks above are the only place the unescaped forms are read. */
203
+ let dirOnly = false;
204
+ if (line.endsWith("/")) {
205
+ dirOnly = true;
206
+ line = line.slice(0, -1);
207
+ }
208
+ /* A slash anywhere but the end anchors the pattern to the `.gitignore`'s own directory; a
209
+ slash-less pattern matches its name at any depth below it. */
210
+ let anchored = line.includes("/");
211
+ if (line.startsWith("/")) {
212
+ line = line.slice(1);
213
+ anchored = true;
214
+ }
215
+ if (line === "") {
216
+ return null;
217
+ }
218
+ const body = globToRegExpSource(line);
219
+ return {
220
+ dirOnly,
221
+ negated,
222
+ re: new RegExp(`^${anchored ? "" : "(?:.*/)?"}${body}$`),
223
+ source: raw,
224
+ };
225
+ }
226
+
227
+ /** Compile a `.gitignore`'s text into rules, in file order. */
228
+ export function parseGitignore(text: string): IgnoreRule[] {
229
+ const rules: IgnoreRule[] = [];
230
+ for (const raw of text.split(/\r?\n/)) {
231
+ const rule = parseLine(raw);
232
+ if (rule) {
233
+ rules.push(rule);
234
+ }
235
+ }
236
+ return rules;
237
+ }
238
+
239
+ // ─── Paths ────────────────────────────────────────────────────────────────────
240
+
241
+ /** Project-relative, forward-slashed, and without the `.`/`./`/trailing-slash spellings of "here". */
242
+ export function normalizePath(path: string): string {
243
+ const p = path
244
+ .replaceAll("\\", "/")
245
+ .replace(/^\.\/+/, "")
246
+ .replace(/\/+$/, "");
247
+ return p === "." ? "" : p;
248
+ }
249
+
250
+ /** `path` relative to `base`, or `null` when it is not underneath it. */
251
+ function relativeTo(base: string, path: string): string | null {
252
+ if (base === "") {
253
+ return path;
254
+ }
255
+ return path.startsWith(`${base}/`) ? path.slice(base.length + 1) : null;
256
+ }
257
+
258
+ /** The project root, then each directory down to `dir`, inclusive. */
259
+ export function ancestorDirs(dir: string): string[] {
260
+ const base = normalizePath(dir);
261
+ const chain = [""];
262
+ if (base === "") {
263
+ return chain;
264
+ }
265
+ let acc = "";
266
+ for (const segment of base.split("/")) {
267
+ acc = acc === "" ? segment : `${acc}/${segment}`;
268
+ chain.push(acc);
269
+ }
270
+ return chain;
271
+ }
272
+
273
+ // ─── Matching ─────────────────────────────────────────────────────────────────
274
+
275
+ /**
276
+ * The verdict on one path, judged on its own.
277
+ *
278
+ * Last match wins, and later layers are consulted after earlier ones — which is exactly git's two
279
+ * precedence rules (a later line beats an earlier one; a deeper `.gitignore` beats a shallower one)
280
+ * expressed as a single ordering.
281
+ */
282
+ function verdictFor(layers: readonly IgnoreLayer[], path: string, isDir: boolean): boolean {
283
+ let ignored = false;
284
+ for (const layer of layers) {
285
+ const rel = relativeTo(layer.base, path);
286
+ if (rel === null || rel === "") {
287
+ continue;
288
+ }
289
+ for (const rule of layer.rules) {
290
+ if (rule.dirOnly && !isDir) {
291
+ continue;
292
+ }
293
+ if (rule.re.test(rel)) {
294
+ ignored = !rule.negated;
295
+ }
296
+ }
297
+ }
298
+ return ignored;
299
+ }
300
+
301
+ /**
302
+ * Whether `path` is ignored by `layers`, which must be ordered shallow-to-deep.
303
+ *
304
+ * Every ancestor directory is judged before the path itself, and the first one that comes back
305
+ * ignored ends it. That is `gitignore(5)`'s "it is not possible to re-include a file if a parent
306
+ * directory of that file is excluded", and it is not decoration: `node_modules` and `dist/` are the
307
+ * two patterns every project has, and judging `node_modules/pkg/index.js` on its own says NOT
308
+ * ignored — the pattern names `node_modules`, and the path is not it. Checked against `git
309
+ * check-ignore` over a matrix of thirty-five paths, this rule is the difference between agreeing
310
+ * with git everywhere and disagreeing on the two cases that matter most.
311
+ *
312
+ * The sidebar happens not to depend on it — an ignored directory draws no row, so nothing ever asks
313
+ * about its children — but a predicate that is only correct for the caller that exists today is a
314
+ * trap for the next one.
315
+ */
316
+ export function isIgnored(layers: readonly IgnoreLayer[], path: string, isDir: boolean): boolean {
317
+ const target = normalizePath(path);
318
+ if (target === "") {
319
+ return false;
320
+ }
321
+ const segments = target.split("/");
322
+ let prefix = "";
323
+ for (const [index, segment] of segments.entries()) {
324
+ prefix = prefix === "" ? segment : `${prefix}/${segment}`;
325
+ const last = index === segments.length - 1;
326
+ // Every ancestor is a directory; only the final segment is what the caller said it is.
327
+ if (verdictFor(layers, prefix, last ? isDir : true)) {
328
+ return true;
329
+ }
330
+ }
331
+ return false;
332
+ }
333
+
334
+ // ─── The loaded layers ────────────────────────────────────────────────────────
335
+
336
+ /** Directory → its own `.gitignore` compiled, or `null` for "looked, there is none". */
337
+ const layerCache = new Map<string, IgnoreLayer | null>();
338
+ /** Reads in progress, so a burst of sibling listings makes one request per directory. */
339
+ const inFlight = new Map<string, Promise<IgnoreLayer | null>>();
340
+
341
+ /** Forget every layer. Called when the project changes and when a `.gitignore` does. */
342
+ export function resetIgnoreCache(): void {
343
+ layerCache.clear();
344
+ inFlight.clear();
345
+ }
346
+
347
+ /**
348
+ * Read and compile one directory's `.gitignore`. Uncached, and never rejects.
349
+ *
350
+ * A directory without a `.gitignore` is the common case rather than an error, and a backend that
351
+ * fails the read for some other reason gets the same answer — "no rules" shows the file, and
352
+ * showing a file the author meant to hide is the only harmless way to be wrong here. The call is
353
+ * wrapped rather than awaited directly because a platform adapter is free to throw synchronously.
354
+ */
355
+ async function readGitignore(dir: string): Promise<IgnoreLayer | null> {
356
+ try {
357
+ const text = await getPlatform().readFile(dir === "" ? ".gitignore" : `${dir}/.gitignore`);
358
+ return { base: dir, rules: parseGitignore(text) };
359
+ } catch {
360
+ return null;
361
+ }
362
+ }
363
+
364
+ /** {@link readGitignore}, once per directory, with concurrent callers sharing the one read. */
365
+ function readLayer(dir: string): Promise<IgnoreLayer | null> {
366
+ const cached = layerCache.get(dir);
367
+ if (cached !== undefined) {
368
+ return Promise.resolve(cached);
369
+ }
370
+ const pending = inFlight.get(dir);
371
+ if (pending) {
372
+ return pending;
373
+ }
374
+ const read = readGitignore(dir).then((layer) => {
375
+ layerCache.set(dir, layer);
376
+ inFlight.delete(dir);
377
+ return layer;
378
+ });
379
+ inFlight.set(dir, read);
380
+ return read;
381
+ }
382
+
383
+ /**
384
+ * Re-read every `.gitignore` the tree has already consulted, and swap the results in together.
385
+ *
386
+ * Read-then-swap rather than clear-then-read, and that is the whole point of the function: an empty
387
+ * cache means "nothing is ignored", so a repaint landing between the two would draw a
388
+ * `node_modules` and then take it away again. Only directories already probed are re-read — the
389
+ * ones nobody has listed will read their rules when someone does.
390
+ */
391
+ export async function reloadIgnoreCache(): Promise<void> {
392
+ const dirs = [...layerCache.keys()];
393
+ inFlight.clear();
394
+ const fresh = await Promise.all(dirs.map((dir) => readGitignore(dir)));
395
+ layerCache.clear();
396
+ for (const [index, dir] of dirs.entries()) {
397
+ layerCache.set(dir, fresh[index] ?? null);
398
+ }
399
+ }
400
+
401
+ /** Load every `.gitignore` governing `dir`, so {@link isIgnoredEntry} can answer synchronously. */
402
+ export async function ensureIgnoreLayers(dir: string): Promise<void> {
403
+ await Promise.all(ancestorDirs(dir).map((ancestor) => readLayer(ancestor)));
404
+ }
405
+
406
+ /** The already-loaded layers governing `dir`, shallow-to-deep. */
407
+ export function loadedLayersFor(dir: string): IgnoreLayer[] {
408
+ const layers: IgnoreLayer[] = [];
409
+ for (const ancestor of ancestorDirs(dir)) {
410
+ const layer = layerCache.get(ancestor);
411
+ if (layer) {
412
+ layers.push(layer);
413
+ }
414
+ }
415
+ return layers;
416
+ }
417
+
418
+ /**
419
+ * Whether the entry at `path`, listed under `dir`, is masked by `.gitignore`.
420
+ *
421
+ * Synchronous, and answers `false` for anything whose layers have not been read yet — a row that
422
+ * appears and then disappears one frame later is a worse artefact than a row that stayed. The
423
+ * listing path awaits {@link ensureIgnoreLayers}, so that frame does not normally exist.
424
+ */
425
+ export function isIgnoredEntry(dir: string, path: string, isDir: boolean): boolean {
426
+ return isIgnored(loadedLayersFor(dir), path, isDir);
427
+ }
428
+
429
+ /** Whether any of these paths is a `.gitignore`, i.e. whether the cache is now stale. */
430
+ export function touchesGitignore(paths: readonly string[]): boolean {
431
+ return paths.some((path) => normalizePath(path).split("/").pop() === ".gitignore");
432
+ }
@@ -25,27 +25,22 @@
25
25
  * as written rather than on the file as stored.
26
26
  *
27
27
  * Everything here is pure string math over project-relative, forward-slashed paths. The only input
28
- * from outside is the open project's content sections, read the same way `content-assets.ts` reads
29
- * them.
28
+ * from outside is the open project's content sections, read the same way `canvas/asset-refs.ts`
29
+ * reads them.
30
30
  *
31
31
  * @docs studio/projects/media
32
32
  */
33
33
 
34
- import { contentMountFor } from "../canvas/content-assets";
34
+ import { encodeProjectPath, normalizeProjectPath, PUBLIC_DIR } from "@jxsuite/schema/asset-paths";
35
+ import { contentMountFor, hostAssetDeclarations, projectLocales } from "../canvas/asset-refs";
36
+ import { loopbackAssetSrc } from "../canvas/canvas-origin";
35
37
  import { projectState } from "../store";
36
38
  import type { AssetMount } from "@jxsuite/schema/asset-paths";
37
39
  import type { ContentSectionEntry } from "../types";
38
40
 
39
- /** The directory whose contents are served from the site root. */
40
- const PUBLIC_DIR = "public";
41
-
42
- /**
43
- * A project-relative path in the one spelling every function here expects: forward slashes, no `./`
44
- * prefix, no leading `/`, no trailing `/`.
45
- */
46
- export function normalizeProjectPath(path: string): string {
47
- return path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/^\/+/, "").replace(/\/+$/, "");
48
- }
41
+ /* Re-exported rather than redefined: the same normalization is what the compiler, the servers and
42
+ the canvas resolver all key on, so there is one definition of it in `@jxsuite/schema`. */
43
+ export { normalizeProjectPath } from "@jxsuite/schema/asset-paths";
49
44
 
50
45
  /** The filename of a project-relative path. */
51
46
  export function baseName(path: string): string {
@@ -75,7 +70,7 @@ function contentSections(): Record<string, ContentSectionEntry> | null {
75
70
  * rather than duplicating the loop.
76
71
  */
77
72
  function mountOf(path: string): AssetMount | null {
78
- return contentMountFor(path, contentSections());
73
+ return contentMountFor(path, contentSections(), projectLocales());
79
74
  }
80
75
 
81
76
  /**
@@ -139,3 +134,31 @@ export function authoredRefTargets(path: string): string[] {
139
134
  }
140
135
  return targets;
141
136
  }
137
+
138
+ /**
139
+ * The src a PARENT-REALM preview should load for a PROJECT FILE PATH — a library thumbnail, a
140
+ * media-browser tile.
141
+ *
142
+ * The sibling of `previewAssetSrc` in `canvas/asset-refs`, and separate from it because the two
143
+ * take different things and confusing them is silent. A file path is NOT an authored reference:
144
+ * `public/hero.jpg` is written `/hero.jpg`, a string that shares not one segment with it. The
145
+ * library grid built its `<img src>` by prefixing the path with a slash, so every `public/` image
146
+ * asked for `/public/hero.jpg` — a URL the site does not publish — and every content-collection
147
+ * image skipped its mount entirely.
148
+ *
149
+ * @param {string} path - A project-relative file path, as the file tree spells it
150
+ * @returns {string} A src the parent realm can load
151
+ */
152
+ export function previewFileSrc(path: string): string {
153
+ const normalized = normalizeProjectPath(path);
154
+ if (normalized === "") {
155
+ return path;
156
+ }
157
+ /* In repo space nothing answers a site URL, so the site URL is the wrong question entirely: the
158
+ host serves the FILE, at its own path, under the declared base. */
159
+ const { space, fileBaseUrl } = hostAssetDeclarations();
160
+ if (space === "repo") {
161
+ return `${fileBaseUrl}${encodeProjectPath(normalized)}`;
162
+ }
163
+ return loopbackAssetSrc(mediaSiteUrl(normalized));
164
+ }
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { errorMessage } from "@jxsuite/schema/parse";
20
- import { getPlatform } from "../platform";
20
+ import { getPlatform, hasPlatform } from "../platform";
21
21
  import { notify } from "../services/notify";
22
22
  import { activeTab } from "../workspace/workspace";
23
23
 
@@ -50,8 +50,20 @@ export const MEDIA_EXTENSIONS = new Set([
50
50
  ".otf",
51
51
  ]);
52
52
 
53
- /** The `accept` attribute for every upload file input. */
54
- export const UPLOAD_ACCEPT = [
53
+ /** A byte count as a person reads it, for a refusal that names the limit. */
54
+ function formatBytes(bytes: number): string {
55
+ const units = ["B", "KB", "MB", "GB"];
56
+ let value = bytes;
57
+ let unit = 0;
58
+ while (value >= 1024 && unit < units.length - 1) {
59
+ value /= 1024;
60
+ unit += 1;
61
+ }
62
+ return `${unit === 0 ? value : Number(value.toFixed(1))} ${units[unit]}`;
63
+ }
64
+
65
+ /** What Studio itself offers, before any backend narrows it. */
66
+ const DEFAULT_ACCEPT = [
55
67
  "image/*",
56
68
  "video/*",
57
69
  "audio/*",
@@ -63,6 +75,20 @@ export const UPLOAD_ACCEPT = [
63
75
  ".otf",
64
76
  ].join(",");
65
77
 
78
+ /**
79
+ * The `accept` attribute for every upload file input.
80
+ *
81
+ * A backend may NARROW it by declaring `assetCapabilities.accept`; nothing widens it, because the
82
+ * picker offering a type Studio cannot place in a document helps no one. Read as a function, not a
83
+ * constant, because the platform is registered after this module is imported.
84
+ *
85
+ * @returns {string} An `<input accept>` value
86
+ */
87
+ export function uploadAccept(): string {
88
+ const declared = hasPlatform() ? getPlatform().assetCapabilities?.accept : undefined;
89
+ return declared ?? DEFAULT_ACCEPT;
90
+ }
91
+
66
92
  /** What kind of element an uploaded asset wants to become. */
67
93
  export type AssetKind = "image" | "video" | "audio" | "file";
68
94
 
@@ -225,18 +251,33 @@ export async function uploadAssets(
225
251
  taken = new Set(); // Directory doesn't exist yet — the backend mkdir -p's on write.
226
252
  }
227
253
 
254
+ /* A DECLARED limit, or none. Studio must not invent one: a limit it made up is a file the user
255
+ cannot upload for no reason anyone can name. A declared one is different — refusing here saves
256
+ the round trip and lets the refusal say the number. */
257
+ const maxBytes = hasPlatform() ? getPlatform().assetCapabilities?.maxUploadBytes : undefined;
258
+
228
259
  const uploaded: UploadedAsset[] = [];
229
260
  for (const file of list) {
261
+ if (maxBytes !== undefined && file.size > maxBytes) {
262
+ notify.error(`${file.name} is too large.`, {
263
+ detail: `This backend accepts at most ${formatBytes(maxBytes)}; that file is ${formatBytes(file.size)}.`,
264
+ source: "Media",
265
+ });
266
+ continue;
267
+ }
230
268
  const name = uniqueName(file.name, taken);
231
269
  taken.add(name);
232
- const path = joinDir(dir, name);
270
+ const requested = joinDir(dir, name);
233
271
  try {
234
- await platform.uploadFile(path, file);
272
+ /* The path the backend REPORTS, not the one asked for. A store that de-duplicates by content
273
+ hash, appends a collision suffix, or normalizes a name writes somewhere else, and the
274
+ reference that goes into the document has to name what is really there. */
275
+ const { path } = await platform.uploadFile(requested, file);
235
276
  uploaded.push({ kind: mediaKind(file), name, path, ref: assetRef(path) });
236
277
  } catch (error) {
237
278
  notify.error(`Could not upload ${file.name}.`, {
238
279
  detail: errorMessage(error),
239
- path,
280
+ path: requested,
240
281
  source: "Media",
241
282
  });
242
283
  }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * A tab's document, as the bytes a file would hold.
3
+ *
4
+ * Its own module because four callers need it and one of them is `file-ops` itself: the save path
5
+ * writes what this returns, the source view shows it, the collab layer publishes it, and the live
6
+ * preview overlay sends it. Leaving it in `file-ops` meant the overlay had to import the module
7
+ * that imports the overlay, and the collab layer had already worked around the same shape by
8
+ * injecting the function at boot. One home resolves both.
9
+ *
10
+ * That every one of them goes through this function is the point rather than an accident: what a
11
+ * reader sees in a preview, what a peer sees in a session and what lands on disk are the same
12
+ * bytes, so they cannot drift.
13
+ */
14
+
15
+ import { getGridController } from "../grid/grid-controller";
16
+ import {
17
+ defaultContentFormat,
18
+ formatByName,
19
+ formatSerialize,
20
+ loadFormats,
21
+ } from "../format/format-host";
22
+ import type { Tab } from "../tabs/tab";
23
+
24
+ /**
25
+ * Serialize the current document to its output format. Format tabs round-trip through the format
26
+ * class's serialize capability; everything else is native JSON.
27
+ *
28
+ * @param {import("../tabs/tab.js").Tab} tab
29
+ * @returns {Promise<string>}
30
+ */
31
+ export async function serializeDocument(tab: Tab): Promise<string> {
32
+ // Grid tabs serialize through their source (pending edits included) — e.g. the Monaco source
33
+ // View of a CSV grid tab shows the live file text.
34
+ const grid = getGridController(tab);
35
+ const gridText = grid?.serializeForSource();
36
+ if (gridText) {
37
+ return gridText;
38
+ }
39
+ await loadFormats();
40
+ const sourceFormat = formatByName(tab.doc.sourceFormat);
41
+ if (sourceFormat?.capabilities.serialize) {
42
+ const fm = tab.doc.content?.frontmatter || {};
43
+ const doc = tab.doc.document;
44
+ const fullDoc = { ...fm, ...doc, children: doc.children ?? [] };
45
+ return formatSerialize(sourceFormat.name, fullDoc, { mode: "roundtrip" });
46
+ }
47
+ if (tab.doc.mode === "content") {
48
+ const format = defaultContentFormat();
49
+ if (format) {
50
+ const fm = tab.doc.content?.frontmatter ?? {};
51
+ const hasFrontmatter = Object.keys(fm).length > 0;
52
+ const fullDoc = { ...fm, ...tab.doc.document };
53
+ return formatSerialize(format.name, fullDoc, {
54
+ frontmatter: hasFrontmatter,
55
+ mode: "roundtrip",
56
+ });
57
+ }
58
+ }
59
+ return JSON.stringify(tab.doc.document, null, 2);
60
+ }