@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.
- package/README.md +5 -5
- package/dist/chunks/Overlay.dev-723bv0df.js +23 -0
- package/dist/chunks/cf-settings-45fgyx8y.js +21 -0
- package/dist/chunks/{components-z6cnkyy1.js → components-zmx10t62.js} +6 -5
- package/dist/chunks/content-source-t88vb2dr.js +49 -0
- package/dist/chunks/{convert-to-repeater-k10dvcc7.js → convert-to-repeater-dhw1nrz9.js} +27 -18
- package/dist/chunks/entry-commands-6z6bhxg0.js +46 -0
- package/dist/chunks/{extension-sections-0ada0xcw.js → extension-sections-3ydgjbdy.js} +32 -23
- package/dist/chunks/file-ops-rskmhxyq.js +48 -0
- package/dist/chunks/files-0cratsr0.js +92 -0
- package/dist/chunks/{format-host-wewba4nq.js → format-host-tgcmfs4p.js} +3 -3
- package/dist/chunks/fs-events-hrrg328g.js +21 -0
- package/dist/chunks/{git-panel-2dh1aw50.js → git-panel-0yywyk76.js} +21 -15
- package/dist/chunks/{jx-validate-jj9rnt4n.js → jx-validate-8q2g05x1.js} +6 -4
- package/dist/chunks/layers-panel-z3tqe5cd.js +67 -0
- package/dist/chunks/layers-q75tj2mf.js +45 -0
- package/dist/chunks/lit-html-j055mkh8.js +22 -0
- package/dist/chunks/monaco-buffer-dqw0xxpx.js +25 -0
- package/dist/chunks/{monaco-setup-a5wkmbgh.js → monaco-setup-rws7k4br.js} +4 -4
- package/dist/chunks/notify-yjm79n4s.js +30 -0
- package/dist/chunks/parse-24d0gay3.js +19 -0
- package/dist/chunks/platform-ba1j1gbh.js +16 -0
- package/dist/chunks/publish-panel-54jvhskq.js +28 -0
- package/dist/chunks/quick-search-cgfmjwq5.js +65 -0
- package/dist/chunks/references-3qx8fkyp.js +29 -0
- package/dist/chunks/right-panel-cqp8bc4v.js +46 -0
- package/dist/chunks/sp-overlay.dev-yztmtrs1.js +18 -0
- package/dist/chunks/sp-popover.dev-n2etmqdp.js +9 -0
- package/dist/chunks/sp-progress-circle.dev-hryhj0es.js +15 -0
- package/dist/chunks/sp-tooltip.dev-xcvpnqr0.js +15 -0
- package/dist/chunks/{sp-tray.dev-64v4pxw8.js → sp-tray.dev-tkbscm06.js} +5 -5
- package/dist/chunks/{studio-f5yyvqxv.js → studio-1hbqnt19.js} +37 -31
- package/dist/chunks/studio-2zkz8evn.js +3163 -0
- package/dist/chunks/{studio-0b6fs05q.js → studio-4pqqxbrt.js} +23 -15
- package/dist/chunks/{studio-kvfx9z2c.js → studio-4rzyrtjz.js} +70 -31
- package/dist/chunks/{studio-ef8t53vc.js → studio-5q69gfag.js} +60 -56
- package/dist/chunks/{studio-v2c5pmjj.js → studio-5rywrkxz.js} +565 -133
- package/dist/chunks/{studio-6kv3apa9.js → studio-68n67094.js} +25 -9
- package/dist/chunks/{studio-0rqqhrdy.js → studio-7nte2hj4.js} +2 -2
- package/dist/chunks/{studio-hyn3vhwr.js → studio-8swrjbny.js} +31 -18
- package/dist/chunks/studio-92ab2jqe.js +282 -0
- package/dist/chunks/{studio-sas7mt3k.js → studio-9ab8gqsm.js} +2 -2
- package/dist/chunks/{studio-28xc8ws4.js → studio-9c4k4vhv.js} +14 -10
- package/dist/chunks/studio-9n4mrmnb.js +184 -0
- package/dist/chunks/{studio-tr20m8g8.js → studio-9r6j5ev1.js} +37 -2
- package/dist/chunks/{studio-aweyr4v1.js → studio-ahjfdy6e.js} +9 -7
- package/dist/chunks/{studio-s519zc99.js → studio-bgxt93ga.js} +3 -3
- package/dist/chunks/{studio-26sv44br.js → studio-cb12yz9w.js} +8543 -8784
- package/dist/chunks/studio-cft9r4r7.js +119 -0
- package/dist/chunks/{studio-rqemfhz6.js → studio-eenfkt4e.js} +6 -6
- package/dist/chunks/studio-eesa1myv.js +498 -0
- package/dist/chunks/{studio-ay8wcr2b.js → studio-eqq1w638.js} +7 -9
- package/dist/chunks/{studio-ke244pam.js → studio-ex8zyp98.js} +51 -25
- package/dist/chunks/studio-gv8vvshc.js +118 -0
- package/dist/chunks/studio-hbtp0eh1.js +1315 -0
- package/dist/chunks/{studio-cz2j1c0g.js → studio-htsf0w6m.js} +75 -123
- package/dist/chunks/{studio-pyrw8h0w.js → studio-kjjsjak4.js} +528 -212
- package/dist/chunks/{studio-5x5774ak.js → studio-kryvsw6j.js} +3 -3
- package/dist/chunks/studio-kyh9avvv.js +28 -0
- package/dist/chunks/{studio-jnakhg87.js → studio-kyk2v92a.js} +14 -14
- package/dist/chunks/{studio-twsafr1e.js → studio-m3hwn7y1.js} +3 -3
- package/dist/chunks/{studio-tzx3xmvx.js → studio-mmv72a27.js} +8 -8
- package/dist/chunks/{studio-vhk5fmfv.js → studio-n8b9wqwy.js} +5 -5
- package/dist/chunks/{studio-cjm7fdjf.js → studio-p4ngc046.js} +8 -104
- package/dist/chunks/{studio-sccge2wf.js → studio-p8j86wag.js} +2 -2
- package/dist/chunks/{studio-v1va0vhd.js → studio-pdfjntbk.js} +75 -139
- package/dist/chunks/studio-q43a5y9h.js +394 -0
- package/dist/chunks/studio-q7v10cg2.js +106 -0
- package/dist/chunks/{studio-2w11grav.js → studio-qq6jym04.js} +3 -3
- package/dist/chunks/studio-r1r7afxx.js +1066 -0
- package/dist/chunks/{studio-3fk1ac9z.js → studio-rdx8jcxy.js} +4 -4
- package/dist/chunks/{studio-4mqb9a0j.js → studio-rn9ta2mz.js} +4 -4
- package/dist/chunks/studio-sf7zjgv1.js +91 -0
- package/dist/chunks/studio-t7qb9db8.js +665 -0
- package/dist/chunks/studio-tmc5emrw.js +8 -0
- package/dist/chunks/{studio-wdt9y92a.js → studio-txwkm6p9.js} +2 -2
- package/dist/chunks/{studio-dgj7gq44.js → studio-v38hmvx1.js} +7 -7
- package/dist/chunks/{studio-7ejemhej.js → studio-vadzgkrz.js} +6 -6
- package/dist/chunks/studio-wcy8trfx.js +263 -0
- package/dist/chunks/{studio-hb7c46jy.js → studio-wx5609xd.js} +21 -15
- package/dist/chunks/studio-xrapxwwc.js +1539 -0
- package/dist/chunks/{studio-ws6ef6js.js → studio-yxrkyv8a.js} +41 -29
- package/dist/chunks/{studio-xm3dbxvn.js → studio-zczps70q.js} +2 -2
- package/dist/chunks/workspace-xpres40v.js +89 -0
- package/dist/iframe-entry.js +450 -48
- package/dist/studio.js +2455 -1761
- package/package.json +16 -15
- package/src/browse/library-layouts.ts +3 -3
- package/src/browse/library-pane.ts +59 -5
- package/src/browse/library-preview.ts +19 -3
- package/src/canvas/asset-refs.ts +119 -0
- package/src/canvas/asset-resolve.ts +256 -0
- package/src/canvas/canvas-live-render.ts +23 -15
- package/src/canvas/canvas-render.ts +23 -1
- package/src/canvas/iframe-entry.ts +1 -0
- package/src/canvas/iframe-host.ts +4 -0
- package/src/canvas/iframe-patch.ts +47 -21
- package/src/canvas/iframe-protocol.ts +9 -0
- package/src/canvas/iframe-render.ts +50 -16
- package/src/commands/app-commands.ts +4 -0
- package/src/commands/command-args.ts +29 -0
- package/src/commands/context.ts +15 -1
- package/src/commands/defaults.ts +29 -1
- package/src/commands/levels.ts +16 -0
- package/src/commands/live-context.ts +3 -0
- package/src/content/collection-match.ts +267 -0
- package/src/content/entry-commands.ts +38 -29
- package/src/content/entry-fields.ts +6 -0
- package/src/editor/file-drop-action.ts +2 -1
- package/src/editor/shortcuts.ts +5 -0
- package/src/files/file-ops.ts +10 -42
- package/src/files/files.ts +487 -61
- package/src/files/fs-events.ts +9 -0
- package/src/files/gitignore.ts +432 -0
- package/src/files/media-paths.ts +37 -14
- package/src/files/media-upload.ts +47 -6
- package/src/files/serialize-document.ts +60 -0
- package/src/format/convert-file.ts +511 -0
- package/src/format/format-choices.ts +199 -0
- package/src/grid/grid-idle.ts +45 -0
- package/src/grid/grid-view.ts +35 -0
- package/src/grid/schema-columns.ts +2 -1
- package/src/grid/sources/content-source.ts +24 -3
- package/src/media/media-open.ts +58 -0
- package/src/media/media-pane.ts +310 -0
- package/src/new-project/import-tab.ts +333 -160
- package/src/new-project/new-project-modal.ts +27 -34
- package/src/packages/ensure-deps.ts +14 -7
- package/src/packages/jxsuite-update.ts +11 -11
- package/src/page-params.ts +10 -46
- package/src/panels/activity-bar.ts +45 -16
- package/src/panels/ai-chat/attached-context.ts +6 -1
- package/src/panels/ai-chat/chat-view.ts +255 -13
- package/src/panels/ai-chat/composer.ts +21 -80
- package/src/panels/ai-panel.ts +116 -2
- package/src/panels/frontmatter-fields.ts +6 -2
- package/src/panels/navigator-panels.ts +7 -6
- package/src/panels/properties-panel.ts +8 -2
- package/src/panels/seo-modal.ts +5 -6
- package/src/panels/settings-menu.ts +799 -0
- package/src/panels/signals-panel.ts +2 -1
- package/src/panels/toolbar.ts +135 -15
- package/src/platforms/cloud.ts +34 -3
- package/src/platforms/devserver.ts +72 -34
- package/src/preview/preview-overlay.ts +226 -0
- package/src/services/ai-ask.ts +247 -0
- package/src/services/ai-import-tools.ts +494 -0
- package/src/services/ai-models.ts +101 -11
- package/src/services/ai-project-tools.ts +20 -26
- package/src/services/ai-settings.ts +69 -71
- package/src/services/ai-system-prompt.ts +88 -2
- package/src/services/ai-turn-signal.ts +70 -0
- package/src/services/cf-settings.ts +19 -37
- package/src/services/connection.ts +74 -0
- package/src/services/context-manager.ts +89 -0
- package/src/services/document-assistant.ts +49 -7
- package/src/services/idle.ts +6 -1
- package/src/services/import-client.ts +33 -6
- package/src/services/import-run.ts +158 -0
- package/src/services/import-seed.ts +120 -0
- package/src/services/jx-validate.ts +58 -6
- package/src/services/project-adoption.ts +71 -0
- package/src/services/references.ts +0 -0
- package/src/services/settings/definitions.ts +181 -0
- package/src/services/settings/kernel.ts +335 -0
- package/src/services/settings/write-queue.ts +116 -0
- package/src/services/tool-executor.ts +34 -2
- package/src/settings/contexts-section.ts +45 -3
- package/src/settings/dependencies-editor.ts +43 -23
- package/src/settings/general-settings.ts +30 -12
- package/src/settings/preferences-accounts.ts +6 -19
- package/src/settings/preferences-dialog.ts +18 -41
- package/src/settings/preferences-keymap.ts +8 -18
- package/src/settings/preferences-sections.ts +57 -0
- package/src/settings/schema-field-ui.ts +2 -2
- package/src/settings/settings-document.ts +65 -9
- package/src/shell.ts +6 -14
- package/src/studio.ts +65 -7
- package/src/tabs/transact.ts +42 -19
- package/src/types.ts +164 -8
- package/src/ui/ai-credentials-form.ts +52 -27
- package/src/ui/ai-model-picker.ts +162 -0
- package/src/ui/choice-field.ts +72 -0
- package/src/ui/layers.ts +83 -5
- package/src/ui/media-picker.ts +6 -11
- package/src/utils/studio-utils.ts +43 -31
- package/styles/forced-colors.css +7 -0
- package/styles/overlays.css +256 -0
- package/styles/panels.css +13 -1
- package/styles/shell.css +111 -0
- package/dist/chunks/Overlay.dev-d2ajy6sj.js +0 -23
- package/dist/chunks/cf-settings-g5fbf2zp.js +0 -17
- package/dist/chunks/content-source-k9724vcv.js +0 -39
- package/dist/chunks/files-yew27sn9.js +0 -69
- package/dist/chunks/layers-panel-qnsghwwd.js +0 -59
- package/dist/chunks/publish-panel-a0pdft5w.js +0 -21
- package/dist/chunks/quick-search-9avyc4pc.js +0 -54
- package/dist/chunks/right-panel-kcnhtc5j.js +0 -38
- package/dist/chunks/sp-overlay.dev-s18hymn9.js +0 -18
- package/dist/chunks/sp-popover.dev-dj9x7e7a.js +0 -9
- package/dist/chunks/sp-progress-circle.dev-0zxcyz3r.js +0 -15
- package/dist/chunks/sp-tooltip.dev-5c6d1kjz.js +0 -15
- package/dist/chunks/studio-a7pzveq1.js +0 -110
- package/dist/chunks/studio-c31yb4t6.js +0 -55
- package/dist/chunks/studio-ra7ar50f.js +0 -6682
- package/src/canvas/content-assets.ts +0 -263
- package/src/canvas/site-style-css.ts +0 -121
- package/src/services/settings-store.ts +0 -99
package/src/files/fs-events.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/files/media-paths.ts
CHANGED
|
@@ -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 `
|
|
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 {
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
/**
|
|
54
|
-
|
|
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
|
|
270
|
+
const requested = joinDir(dir, name);
|
|
233
271
|
try {
|
|
234
|
-
|
|
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
|
+
}
|