@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/files.ts
CHANGED
|
@@ -29,14 +29,18 @@ import { ref } from "lit-html/directives/ref.js";
|
|
|
29
29
|
import { renderPopover, showPromptDialog } from "../ui/layers";
|
|
30
30
|
import { projectState, requireProjectState, setProjectState } from "../store";
|
|
31
31
|
import { getPlatform } from "../platform";
|
|
32
|
+
import { disarmPreviewOverlay } from "../preview/preview-overlay";
|
|
32
33
|
import { notify } from "../services/notify";
|
|
33
34
|
import { loadComponentRegistry } from "./components";
|
|
34
35
|
import { ensureDependenciesInstalled } from "../packages/ensure-deps";
|
|
35
36
|
import { maybePromptJxsuiteUpdate } from "../packages/jxsuite-update";
|
|
36
37
|
import { autoSyncProjectOnOpen } from "../packages/pull-package-sync";
|
|
37
38
|
import { markLocalMutation } from "./fs-events";
|
|
39
|
+
import { ensureIgnoreLayers, isIgnoredEntry, resetIgnoreCache } from "./gitignore";
|
|
40
|
+
import { SETTINGS } from "../services/settings/definitions";
|
|
41
|
+
import { readStoredSetting, setSetting } from "../services/settings/kernel";
|
|
38
42
|
import { registerPanel } from "../panels/panel-registry";
|
|
39
|
-
import {
|
|
43
|
+
import { isImage, uploadAccept, uploadAssets } from "./media-upload";
|
|
40
44
|
import { isCollabPath } from "../collab/collab-state";
|
|
41
45
|
import {
|
|
42
46
|
draggable,
|
|
@@ -56,19 +60,22 @@ import {
|
|
|
56
60
|
workspace,
|
|
57
61
|
} from "../workspace/workspace";
|
|
58
62
|
import { openCsvGridTab, openPagesGrid } from "../grid/grid-open";
|
|
59
|
-
import {
|
|
63
|
+
import { isViewableMedia, openMediaTab } from "../media/media-open";
|
|
60
64
|
import { activeRegistry } from "../commands/active-registry";
|
|
61
65
|
import { collectionOfPath } from "../content/entry-model";
|
|
62
66
|
import { confirmFileDelete, parseSourceForPath, renamePromptMessage } from "./file-ops";
|
|
63
67
|
import { invalidateUsages } from "../services/references";
|
|
64
68
|
import {
|
|
65
69
|
documentExtensions,
|
|
70
|
+
formatByExtension,
|
|
66
71
|
formatForPath,
|
|
67
72
|
loadFormats,
|
|
68
73
|
noFormatError,
|
|
69
74
|
refreshExtensionUi,
|
|
70
75
|
refreshFormats,
|
|
71
76
|
} from "../format/format-host";
|
|
77
|
+
import { convertTargets, creationFormats, knownDocumentExtensions } from "../format/format-choices";
|
|
78
|
+
import { collectionForDirectory } from "../content/collection-match";
|
|
72
79
|
import { markSessionRestored, persistedSession, resetProjectShell, setActivityTab } from "../shell";
|
|
73
80
|
import { restoreSession } from "../workspace/session";
|
|
74
81
|
import { cleanupGitPanel } from "../panels/git-panel";
|
|
@@ -82,6 +89,7 @@ import {
|
|
|
82
89
|
import type { TemplateResult } from "lit-html";
|
|
83
90
|
import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
84
91
|
import type { ResolvedI18n } from "@jxsuite/schema/locale";
|
|
92
|
+
import type { ChoiceOption } from "../ui/choice-field";
|
|
85
93
|
import type { DirEntry, RenameResult } from "../types";
|
|
86
94
|
import type { ListWindowWatch } from "../ui/virtual-window";
|
|
87
95
|
import { rectOf } from "../utils/geometry";
|
|
@@ -106,13 +114,34 @@ export async function loadDirectory(dirPath: string) {
|
|
|
106
114
|
}
|
|
107
115
|
try {
|
|
108
116
|
const platform = getPlatform();
|
|
109
|
-
|
|
117
|
+
/* The `.gitignore` chain is fetched WITH the listing, not after it: `collectFileRows` reads the
|
|
118
|
+
ignore rules synchronously while it builds rows, and a repaint that beat the rules would draw
|
|
119
|
+
a `node_modules` and then take it away again. Concurrent, because neither needs the other. */
|
|
120
|
+
const [entries] = await Promise.all([
|
|
121
|
+
platform.listDirectory(dirPath),
|
|
122
|
+
ensureIgnoreLayers(dirPath),
|
|
123
|
+
]);
|
|
110
124
|
projectState.dirs.set(dirPath, entries);
|
|
111
125
|
} catch {
|
|
112
126
|
projectState.dirs.set(dirPath, []);
|
|
113
127
|
}
|
|
114
128
|
}
|
|
115
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Whether the tree currently draws the files `.gitignore` masks.
|
|
132
|
+
*
|
|
133
|
+
* Read through the kernel on every render rather than cached in a module variable: the setting
|
|
134
|
+
* roams, so another window can change it, and this one repaints on `onSettingsChanged`.
|
|
135
|
+
*/
|
|
136
|
+
export function showIgnoredFiles(): boolean {
|
|
137
|
+
return readStoredSetting(SETTINGS.showIgnoredFiles) === "true";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Set whether the tree draws the files `.gitignore` masks. */
|
|
141
|
+
export function setShowIgnoredFiles(show: boolean): void {
|
|
142
|
+
setSetting(SETTINGS.showIgnoredFiles, show ? "true" : "");
|
|
143
|
+
}
|
|
144
|
+
|
|
116
145
|
/** Probe the dev server for a root project and populate projectState. */
|
|
117
146
|
export async function loadProject() {
|
|
118
147
|
try {
|
|
@@ -126,6 +155,7 @@ export async function loadProject() {
|
|
|
126
155
|
refreshFormats();
|
|
127
156
|
void loadFormats();
|
|
128
157
|
refreshExtensionUi(platform);
|
|
158
|
+
resetIgnoreCache();
|
|
129
159
|
|
|
130
160
|
setProjectState({
|
|
131
161
|
dirs: new Map(),
|
|
@@ -148,7 +178,14 @@ export async function loadProject() {
|
|
|
148
178
|
if (info.isSiteProject) {
|
|
149
179
|
addRecentProject(requireProjectState().name, meta.root);
|
|
150
180
|
await autoSyncProjectOnOpen();
|
|
151
|
-
await ensureDependenciesInstalled()
|
|
181
|
+
if (await ensureDependenciesInstalled()) {
|
|
182
|
+
// The registry above was fetched from a project with no `node_modules`, and `loadFormats`
|
|
183
|
+
// Memoises — so without this the picker, the convert targets and every parse in the session
|
|
184
|
+
// Are answered by the empty registry a fresh clone had.
|
|
185
|
+
refreshFormats();
|
|
186
|
+
await loadFormats();
|
|
187
|
+
refreshExtensionUi(platform);
|
|
188
|
+
}
|
|
152
189
|
await loadDirectory(".");
|
|
153
190
|
await loadComponentRegistry();
|
|
154
191
|
await openLastSessionOrHome();
|
|
@@ -191,9 +228,14 @@ export async function openProject({
|
|
|
191
228
|
id: "initial",
|
|
192
229
|
});
|
|
193
230
|
|
|
231
|
+
/* A different project means a different preview origin, and the published map is keyed by
|
|
232
|
+
project-relative path — so carrying it across would let one project's `pages/index.json`
|
|
233
|
+
stand in as the answer for another's. */
|
|
234
|
+
disarmPreviewOverlay();
|
|
194
235
|
refreshFormats();
|
|
195
236
|
void loadFormats();
|
|
196
237
|
refreshExtensionUi(platform);
|
|
238
|
+
resetIgnoreCache();
|
|
197
239
|
|
|
198
240
|
setProjectState({
|
|
199
241
|
.../** @type {ProjectState} */ projectState,
|
|
@@ -209,7 +251,12 @@ export async function openProject({
|
|
|
209
251
|
setWorkspaceProject(handle.root, config);
|
|
210
252
|
|
|
211
253
|
await autoSyncProjectOnOpen();
|
|
212
|
-
await ensureDependenciesInstalled()
|
|
254
|
+
if (await ensureDependenciesInstalled()) {
|
|
255
|
+
// See the note in `probeRootProject`: the registry was fetched before the install ran.
|
|
256
|
+
refreshFormats();
|
|
257
|
+
await loadFormats();
|
|
258
|
+
refreshExtensionUi(platform);
|
|
259
|
+
}
|
|
213
260
|
await loadDirectory(".");
|
|
214
261
|
await loadComponentRegistry();
|
|
215
262
|
|
|
@@ -436,6 +483,8 @@ export function renderFilesTemplate({
|
|
|
436
483
|
</div>`;
|
|
437
484
|
}
|
|
438
485
|
|
|
486
|
+
const showingIgnored = showIgnoredFiles();
|
|
487
|
+
|
|
439
488
|
return html`
|
|
440
489
|
${
|
|
441
490
|
projectState.isSiteProject
|
|
@@ -462,6 +511,10 @@ export function renderFilesTemplate({
|
|
|
462
511
|
label="Refresh"
|
|
463
512
|
@click=${async () => {
|
|
464
513
|
requireProjectState().dirs.clear();
|
|
514
|
+
/* The rules go with the listings. Refresh is what an author reaches for after editing a
|
|
515
|
+
`.gitignore` by hand, and a tree that came back still hiding by the old rules would
|
|
516
|
+
read as the button not having worked. */
|
|
517
|
+
resetIgnoreCache();
|
|
465
518
|
await loadDirectory(".");
|
|
466
519
|
for (const dir of requireProjectState().expanded) {
|
|
467
520
|
await loadDirectory(dir);
|
|
@@ -471,6 +524,23 @@ export function renderFilesTemplate({
|
|
|
471
524
|
>
|
|
472
525
|
<sp-icon-refresh slot="icon"></sp-icon-refresh>
|
|
473
526
|
</sp-action-button>
|
|
527
|
+
<sp-action-button
|
|
528
|
+
size="xs"
|
|
529
|
+
label=${showingIgnored ? "Hide ignored files" : "Show ignored files"}
|
|
530
|
+
?selected=${showingIgnored}
|
|
531
|
+
@click=${() => {
|
|
532
|
+
/* A repaint and nothing else: the ignored entries were never dropped from
|
|
533
|
+
`projectState.dirs`, only from the rows built out of it. */
|
|
534
|
+
setShowIgnoredFiles(!showingIgnored);
|
|
535
|
+
renderLeftPanel();
|
|
536
|
+
}}
|
|
537
|
+
>
|
|
538
|
+
${
|
|
539
|
+
showingIgnored
|
|
540
|
+
? html`<sp-icon-visibility slot="icon"></sp-icon-visibility>`
|
|
541
|
+
: html`<sp-icon-visibility-off slot="icon"></sp-icon-visibility-off>`
|
|
542
|
+
}
|
|
543
|
+
</sp-action-button>
|
|
474
544
|
</sp-action-group>
|
|
475
545
|
<sp-search
|
|
476
546
|
size="s"
|
|
@@ -606,10 +676,18 @@ function collectFileRows(
|
|
|
606
676
|
return a.name.localeCompare(b.name);
|
|
607
677
|
});
|
|
608
678
|
|
|
679
|
+
/* Ignored entries are dropped HERE and not at the listing, so `projectState.dirs` goes on
|
|
680
|
+
mirroring the filesystem for everything else that reads it and the toggle is a repaint. An
|
|
681
|
+
ignored directory contributes no row, so nothing ever recurses into one — which is also git's
|
|
682
|
+
rule that a parent's exclusion cannot be undone from inside it. */
|
|
683
|
+
const visible = showIgnoredFiles()
|
|
684
|
+
? sorted
|
|
685
|
+
: sorted.filter((e) => !isIgnoredEntry(dirPath, e.path, e.type === "directory"));
|
|
686
|
+
|
|
609
687
|
const query = requireProjectState().searchQuery.toLowerCase();
|
|
610
688
|
const filtered = query
|
|
611
|
-
?
|
|
612
|
-
:
|
|
689
|
+
? visible.filter((e) => e.type === "directory" || e.name.toLowerCase().includes(query))
|
|
690
|
+
: visible;
|
|
613
691
|
|
|
614
692
|
for (const [index, entry] of filtered.entries()) {
|
|
615
693
|
const isDir = entry.type === "directory";
|
|
@@ -994,7 +1072,7 @@ export function pickAndUploadTo(dir: string, renderLeftPanel: () => void): void
|
|
|
994
1072
|
const input = document.createElement("input");
|
|
995
1073
|
input.type = "file";
|
|
996
1074
|
input.multiple = true;
|
|
997
|
-
input.accept =
|
|
1075
|
+
input.accept = uploadAccept();
|
|
998
1076
|
input.addEventListener("change", () => {
|
|
999
1077
|
if (input.files?.length) {
|
|
1000
1078
|
void uploadFilesToDir(input.files, dir, renderLeftPanel);
|
|
@@ -1170,6 +1248,10 @@ async function moveFileEntry(oldPath: string, newPath: string, renderLeftPanel:
|
|
|
1170
1248
|
markLocalMutation(oldPath, newPath);
|
|
1171
1249
|
try {
|
|
1172
1250
|
const report = await platform.renameFile(oldPath, newPath);
|
|
1251
|
+
// The refactor pass just rewrote references project-wide, and `markLocalMutation` suppresses
|
|
1252
|
+
// The watcher echo that would otherwise say so — hence the explicit drop, as in `renameFile`.
|
|
1253
|
+
// Without it a drag-move leaves every usage count in the session answering about the old path.
|
|
1254
|
+
invalidateUsages();
|
|
1173
1255
|
|
|
1174
1256
|
// Update open tabs referencing the moved path
|
|
1175
1257
|
for (const [id] of workspace.tabs.entries()) {
|
|
@@ -1196,7 +1278,7 @@ async function moveFileEntry(oldPath: string, newPath: string, renderLeftPanel:
|
|
|
1196
1278
|
|
|
1197
1279
|
reloadRewrittenTabs(report, newPath);
|
|
1198
1280
|
renderLeftPanel();
|
|
1199
|
-
|
|
1281
|
+
notifyMoveOutcome(`Moved to ${newPath}`, report, newPath);
|
|
1200
1282
|
} catch (error) {
|
|
1201
1283
|
notify.error(`Could not move ${oldPath}.`, {
|
|
1202
1284
|
detail: errorMessage(error),
|
|
@@ -1253,15 +1335,29 @@ interface FileMenuItem {
|
|
|
1253
1335
|
function fileRowFacts(entry: { path: string; type: string }): Record<string, unknown> {
|
|
1254
1336
|
const facts: Record<string, unknown> = {};
|
|
1255
1337
|
if (entry.type === "directory") {
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1338
|
+
// The collection's own SOURCE ROOT, not any directory beneath it: "Edit Collection in Grid"
|
|
1339
|
+
// Opens the whole collection, so offering it on `content/posts/2026` would be a row whose label
|
|
1340
|
+
// Says one thing and whose action does another.
|
|
1341
|
+
const collection = collectionForDirectory(entry.path);
|
|
1342
|
+
if (collection?.isSourceRoot === true && !collection.fileBacked) {
|
|
1260
1343
|
facts.name = collection.name;
|
|
1261
1344
|
}
|
|
1262
|
-
|
|
1345
|
+
return facts;
|
|
1346
|
+
}
|
|
1347
|
+
if (collectionOfPath(entry.path)) {
|
|
1263
1348
|
facts.path = entry.path;
|
|
1264
1349
|
}
|
|
1350
|
+
/*
|
|
1351
|
+
* `source`, and deliberately not `path`.
|
|
1352
|
+
*
|
|
1353
|
+
* The bag is keyed by ARGUMENT NAME, and `path` already means one specific thing here — "an entry
|
|
1354
|
+
* of a collection" — which is what keeps "Open Entry Form" off `styles/main.css`. A convert
|
|
1355
|
+
* command reusing that key would state it for every convertible file and put the entry form on
|
|
1356
|
+
* all of them. A different question gets a different name.
|
|
1357
|
+
*/
|
|
1358
|
+
if (convertTargets(entry.path).length > 0) {
|
|
1359
|
+
facts.source = entry.path;
|
|
1360
|
+
}
|
|
1265
1361
|
return facts;
|
|
1266
1362
|
}
|
|
1267
1363
|
|
|
@@ -1426,6 +1522,29 @@ const BLANK_DOCUMENT = JSON.stringify(
|
|
|
1426
1522
|
2,
|
|
1427
1523
|
);
|
|
1428
1524
|
|
|
1525
|
+
/**
|
|
1526
|
+
* The picker row meaning "I will type the whole name myself".
|
|
1527
|
+
*
|
|
1528
|
+
* It is what keeps the format picker from being a cage. `New File…` is the only generic
|
|
1529
|
+
* file-creation affordance in Studio and both backends create intermediate directories on write, so
|
|
1530
|
+
* without this row `styles/main.css`, `public/robots.txt`, `.gitignore` and a `credits.txt` beside
|
|
1531
|
+
* a collection's images all become uncreatable — and the picker would have taken away more than it
|
|
1532
|
+
* gave. The picker stays the authority on the extension; this is one of its answers.
|
|
1533
|
+
*/
|
|
1534
|
+
export const OTHER_FORMAT = "__other__";
|
|
1535
|
+
|
|
1536
|
+
/** How the extension of a new file is settled. */
|
|
1537
|
+
export type FormatChoice =
|
|
1538
|
+
/** Picker over every creatable format, plus Other…. The field is a NAME, taken verbatim. */
|
|
1539
|
+
| { kind: "choose"; defaultExt?: string; docKind?: "page" | "component" | "content" }
|
|
1540
|
+
/**
|
|
1541
|
+
* Picker holding ONE format, plus Other… — the collection's own extension. The field is a NAME.
|
|
1542
|
+
* `because` is the sentence a refusal quotes when Other… would smuggle a foreign document in.
|
|
1543
|
+
*/
|
|
1544
|
+
| { kind: "locked"; ext: string; because: string }
|
|
1545
|
+
/** No picker. The field is a DISPLAY NAME, slugified, and `ext` is appended. */
|
|
1546
|
+
| { kind: "fixed"; ext: string };
|
|
1547
|
+
|
|
1429
1548
|
/**
|
|
1430
1549
|
* One creation, named.
|
|
1431
1550
|
*
|
|
@@ -1439,85 +1558,240 @@ export interface NewFileRequest {
|
|
|
1439
1558
|
dir: string;
|
|
1440
1559
|
/** Dialog title — "New File", "New Page", "New Post". Defaults to "New File". */
|
|
1441
1560
|
title?: string;
|
|
1442
|
-
/** Pre-filled value
|
|
1561
|
+
/** Pre-filled value. In picker modes it is a STEM; the picker supplies the extension. */
|
|
1443
1562
|
suggestedName?: string;
|
|
1444
1563
|
/**
|
|
1445
|
-
*
|
|
1446
|
-
*
|
|
1447
|
-
*
|
|
1564
|
+
* How the extension is settled. Absent means the field asks for a whole file name and takes it
|
|
1565
|
+
* verbatim with no picker at all — which is what a caller that already knows the name wants
|
|
1566
|
+
* (`i18n.createTranslation` composes `about.json` from the file it is translating).
|
|
1448
1567
|
*/
|
|
1449
|
-
|
|
1450
|
-
/**
|
|
1451
|
-
|
|
1568
|
+
format?: FormatChoice;
|
|
1569
|
+
/**
|
|
1570
|
+
* Body to write. A function is called with the extension the reader settled on and the composed
|
|
1571
|
+
* file name, which is the only way a caller can seed a body whose FORMAT — or whose own tag name
|
|
1572
|
+
* — it does not know until the dialog resolves. Defaults to the resolved format's
|
|
1573
|
+
* `newFileTemplate`.
|
|
1574
|
+
*/
|
|
1575
|
+
content?:
|
|
1576
|
+
| string
|
|
1577
|
+
| ((ext: string, fileName: string) => string | undefined | Promise<string | undefined>);
|
|
1452
1578
|
/** Who is creating, for the Problem's `source` line. Defaults to "Files". */
|
|
1453
1579
|
source?: string;
|
|
1454
1580
|
}
|
|
1455
1581
|
|
|
1582
|
+
/** A display name, reduced to a file stem. Lowercase by design: this is a slug. */
|
|
1583
|
+
function slugify(input: string): string {
|
|
1584
|
+
return input
|
|
1585
|
+
.trim()
|
|
1586
|
+
.toLowerCase()
|
|
1587
|
+
.replaceAll(/\s+/g, "-")
|
|
1588
|
+
.replaceAll(/[^a-z\d-]/g, "");
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
/** The extension of a file name, including the dot, or `""`. */
|
|
1592
|
+
function extensionOfName(name: string): string {
|
|
1593
|
+
const dot = name.lastIndexOf(".");
|
|
1594
|
+
return dot > 0 ? name.slice(dot) : "";
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
/** The two picker modes — the ones where the field asks for a NAME and the picker owns the suffix. */
|
|
1598
|
+
type PickerChoice = Extract<FormatChoice, { kind: "choose" } | { kind: "locked" }>;
|
|
1599
|
+
|
|
1600
|
+
/** The picker's rows for a request, with the Other… sentinel set apart by a divider. */
|
|
1601
|
+
function formatRowsFor(choice: PickerChoice): ChoiceOption[] {
|
|
1602
|
+
const rows =
|
|
1603
|
+
choice.kind === "locked"
|
|
1604
|
+
? [
|
|
1605
|
+
{
|
|
1606
|
+
ext: choice.ext,
|
|
1607
|
+
label: `${formatByExtension(choice.ext)?.name ?? choice.ext} (${choice.ext})`,
|
|
1608
|
+
},
|
|
1609
|
+
]
|
|
1610
|
+
: creationFormats(choice.docKind);
|
|
1611
|
+
return [
|
|
1612
|
+
...rows.map((row) => ({ label: row.label, value: row.ext })),
|
|
1613
|
+
{ dividerBefore: true, label: "Other…", value: OTHER_FORMAT },
|
|
1614
|
+
];
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1456
1617
|
/**
|
|
1457
|
-
*
|
|
1618
|
+
* Which row starts selected: the caller's stated default, else the extension its prefill already
|
|
1619
|
+
* carries when that is a row, else the first row. A `locked` choice has one format row, so it
|
|
1620
|
+
* always lands there.
|
|
1621
|
+
*/
|
|
1622
|
+
function initialPickFor(choice: PickerChoice, rows: ChoiceOption[], suggested: string): string {
|
|
1623
|
+
const stated = choice.kind === "choose" ? choice.defaultExt : choice.ext;
|
|
1624
|
+
if (stated !== undefined && rows.some((row) => row.value === stated)) {
|
|
1625
|
+
return stated;
|
|
1626
|
+
}
|
|
1627
|
+
const fromPrefill = extensionOfName(suggested);
|
|
1628
|
+
if (fromPrefill !== "" && rows.some((row) => row.value === fromPrefill)) {
|
|
1629
|
+
return fromPrefill;
|
|
1630
|
+
}
|
|
1631
|
+
return rows[0]?.value ?? OTHER_FORMAT;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
/**
|
|
1635
|
+
* Create one file, from the one flow the Files tree, the Library and the content collections use.
|
|
1458
1636
|
*
|
|
1459
|
-
*
|
|
1637
|
+
* Three behaviours the callers used to disagree about, settled here:
|
|
1460
1638
|
*
|
|
1639
|
+
* - **The extension is CHOSEN, not typed.** The project's format registry is what the picker is built
|
|
1640
|
+
* from, so a project that installs a markdown extension offers markdown here with no edit to this
|
|
1641
|
+
* file — and one that installs nothing offers `JSON` and `Other…`, which is the truth. `Other…`
|
|
1642
|
+
* restores the verbatim field for everything that is not a Jx document.
|
|
1461
1643
|
* - **A name that is already taken is refused in the FIELD**, not discovered afterwards. Both
|
|
1462
1644
|
* predecessors called `writeFile` straight onto the composed path, so creating `about.md` in a
|
|
1463
1645
|
* directory that had one silently replaced it — with no undo, because the file was never open.
|
|
1464
1646
|
* The destination is listed once before the prompt so `validate` can say so while it can still be
|
|
1465
|
-
* fixed.
|
|
1647
|
+
* fixed. The comparison is case-INSENSITIVE, because APFS and NTFS are and the write clobbers.
|
|
1466
1648
|
* - **A failure is a Problem carrying the path**, not a toast that scrolls away, since the thing the
|
|
1467
1649
|
* author must do next is about that path.
|
|
1468
1650
|
*
|
|
1469
1651
|
* @returns The created path, or `null` when the author cancelled or the write failed.
|
|
1470
1652
|
*/
|
|
1471
1653
|
export async function createFileIn(request: NewFileRequest): Promise<string | null> {
|
|
1472
|
-
const { dir,
|
|
1654
|
+
const { dir, format: choice, source = "Files" } = request;
|
|
1473
1655
|
await loadFormats();
|
|
1474
1656
|
|
|
1475
1657
|
// One listing, before the field opens: the names it must refuse are known while typing.
|
|
1476
1658
|
let taken = new Set<string>();
|
|
1477
1659
|
try {
|
|
1478
1660
|
const listing = await getPlatform().listDirectory(dir);
|
|
1479
|
-
taken = new Set(listing.map((entry) => entry.name));
|
|
1661
|
+
taken = new Set(listing.map((entry) => entry.name.toLowerCase()));
|
|
1480
1662
|
} catch {
|
|
1481
1663
|
// A directory that cannot be listed is usually one that does not exist yet — the write below
|
|
1482
1664
|
// Is the authority on whether that is a problem, and it reports with the real reason.
|
|
1483
1665
|
}
|
|
1484
1666
|
|
|
1485
|
-
const
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1667
|
+
const picker = choice !== undefined && choice.kind !== "fixed" ? choice : null;
|
|
1668
|
+
const rows = picker === null ? null : formatRowsFor(picker);
|
|
1669
|
+
const initialPick =
|
|
1670
|
+
picker === null || rows === null
|
|
1671
|
+
? ""
|
|
1672
|
+
: initialPickFor(picker, rows, request.suggestedName ?? "");
|
|
1673
|
+
|
|
1674
|
+
/**
|
|
1675
|
+
* The file name a field value and a pick compose to — three modes, not two.
|
|
1676
|
+
*
|
|
1677
|
+
* The verbatim STEM mode is not a convenience. The slugifier lowercases and strips everything
|
|
1678
|
+
* outside `[a-z0-9-]`, which turns `[slug]` into `slug` — and eight shipped starters carry
|
|
1679
|
+
* `pages/[slug].json`, `[sku].json`, `[...slug].json`. A picker that slugified would make a
|
|
1680
|
+
* dynamic route uncreatable and would say nothing about it.
|
|
1681
|
+
*/
|
|
1682
|
+
const fileNameFor = (input: string, picked: string) => {
|
|
1683
|
+
const trimmed = input.trim();
|
|
1684
|
+
if (choice === undefined || picked === OTHER_FORMAT) {
|
|
1685
|
+
return trimmed;
|
|
1686
|
+
}
|
|
1687
|
+
if (choice.kind === "fixed") {
|
|
1688
|
+
const slug = slugify(trimmed);
|
|
1689
|
+
return slug === "" ? "" : `${slug}${choice.ext}`;
|
|
1690
|
+
}
|
|
1691
|
+
return trimmed.toLowerCase().endsWith(picked.toLowerCase()) ? trimmed : `${trimmed}${picked}`;
|
|
1692
|
+
};
|
|
1693
|
+
|
|
1694
|
+
const known = knownDocumentExtensions();
|
|
1695
|
+
|
|
1696
|
+
const validate = (value: string, picked: string) => {
|
|
1697
|
+
const trimmed = value.trim();
|
|
1698
|
+
if (!trimmed) {
|
|
1699
|
+
return choice?.kind === "fixed" ? "Enter a name." : "Enter a file name.";
|
|
1700
|
+
}
|
|
1701
|
+
if (choice?.kind === "fixed" && slugify(trimmed) === "") {
|
|
1702
|
+
return "Enter at least one letter or number.";
|
|
1703
|
+
}
|
|
1704
|
+
const normalized = trimmed.replaceAll("\\", "/");
|
|
1705
|
+
if (normalized.startsWith("/") || normalized.endsWith("/")) {
|
|
1706
|
+
return "Enter a name, not a path that starts or ends with a slash.";
|
|
1707
|
+
}
|
|
1708
|
+
if (normalized.split("/").includes("..")) {
|
|
1709
|
+
return "A name cannot step outside the destination folder.";
|
|
1710
|
+
}
|
|
1711
|
+
// A slash is the tree's ONLY way to make a directory — both backends create them on write — so
|
|
1712
|
+
// It is allowed exactly where the field is a whole file name, and refused where it is a name.
|
|
1713
|
+
if (normalized.includes("/") && choice !== undefined && picked !== OTHER_FORMAT) {
|
|
1714
|
+
return `Pick Other… to create a file in a subfolder of ${dir === "." ? "the project root" : dir}.`;
|
|
1715
|
+
}
|
|
1716
|
+
/* A name whose extension MATCHES the pick is fine and is composed once, never doubled;
|
|
1717
|
+
refusing every trailing extension would reject this dialog's own `untitled.json` default.
|
|
1718
|
+
A name ending in an extension no format claims is a stem that happens to have a dot. */
|
|
1719
|
+
const typedExt = extensionOfName(normalized);
|
|
1720
|
+
if (
|
|
1721
|
+
picked !== "" &&
|
|
1722
|
+
picked !== OTHER_FORMAT &&
|
|
1723
|
+
typedExt !== "" &&
|
|
1724
|
+
typedExt.toLowerCase() !== picked.toLowerCase() &&
|
|
1725
|
+
known.has(typedExt.toLowerCase())
|
|
1726
|
+
) {
|
|
1727
|
+
const label = rows?.find((row) => row.value === picked)?.label ?? picked;
|
|
1728
|
+
return `You picked ${label}; this name ends in ${typedExt}.`;
|
|
1729
|
+
}
|
|
1730
|
+
if (choice?.kind === "locked" && picked === OTHER_FORMAT) {
|
|
1731
|
+
const foreign = typedExt !== "" && typedExt.toLowerCase() !== choice.ext.toLowerCase();
|
|
1732
|
+
if (foreign && known.has(typedExt.toLowerCase())) {
|
|
1733
|
+
return choice.because;
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
/* No emptiness check here: every way `fileNameFor` can answer "" is already refused above — a
|
|
1737
|
+
blank field by the first rule, a display name that slugifies to nothing by the second. */
|
|
1738
|
+
const candidate = fileNameFor(value, picked);
|
|
1739
|
+
return taken.has(candidate.toLowerCase()) ? `${candidate} already exists in ${dir}/.` : "";
|
|
1740
|
+
};
|
|
1493
1741
|
|
|
1742
|
+
let picked = initialPick;
|
|
1494
1743
|
const entered = await showPromptDialog(request.title ?? "New File", {
|
|
1744
|
+
...(rows === null
|
|
1745
|
+
? {}
|
|
1746
|
+
: {
|
|
1747
|
+
choice: {
|
|
1748
|
+
initial: initialPick,
|
|
1749
|
+
label: "Format",
|
|
1750
|
+
onChange: (next: string) => {
|
|
1751
|
+
picked = next;
|
|
1752
|
+
},
|
|
1753
|
+
options: () => rows,
|
|
1754
|
+
},
|
|
1755
|
+
}),
|
|
1495
1756
|
confirmLabel: "Create",
|
|
1496
1757
|
message: dir === "." ? "Creating in the project root." : `Creating in ${dir}/`,
|
|
1497
1758
|
...(request.suggestedName === undefined ? {} : { placeholder: request.suggestedName }),
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
return ext === undefined ? "Enter a file name." : "Enter a name.";
|
|
1502
|
-
}
|
|
1503
|
-
const candidate = fileNameFor(value);
|
|
1504
|
-
if (!candidate || candidate === ext) {
|
|
1505
|
-
return "Enter at least one letter or number.";
|
|
1506
|
-
}
|
|
1507
|
-
return taken.has(candidate) ? `${candidate} already exists in ${dir}/.` : "";
|
|
1508
|
-
},
|
|
1759
|
+
// With a picker there is no extension in the field to preserve, so the whole name is the stem.
|
|
1760
|
+
select: rows === null ? "stem" : "all",
|
|
1761
|
+
validate,
|
|
1509
1762
|
value: request.suggestedName ?? "untitled.json",
|
|
1510
1763
|
});
|
|
1511
1764
|
if (!entered) {
|
|
1512
1765
|
return null;
|
|
1513
1766
|
}
|
|
1514
1767
|
|
|
1515
|
-
const fileName = fileNameFor(entered);
|
|
1768
|
+
const fileName = fileNameFor(entered, picked);
|
|
1516
1769
|
const path = dir === "." ? fileName : `${dir}/${fileName}`;
|
|
1517
1770
|
markLocalMutation(path);
|
|
1518
1771
|
const format = formatForPath(fileName);
|
|
1772
|
+
const suppliedBody =
|
|
1773
|
+
typeof request.content === "function"
|
|
1774
|
+
? await request.content(extensionOfName(fileName), fileName)
|
|
1775
|
+
: request.content;
|
|
1776
|
+
/*
|
|
1777
|
+
* What a new file starts as, in precedence order.
|
|
1778
|
+
*
|
|
1779
|
+
* The `.json` split is the one thing the picker changed. A `.json` the reader CHOSE from the
|
|
1780
|
+
* format picker is a Jx document and gets one; a `data.json` typed through Other… is not, and
|
|
1781
|
+
* gets `{}` — which is at least openable, where the `""` a non-document extension gets would be a
|
|
1782
|
+
* file that reports "no format" the moment it is clicked.
|
|
1783
|
+
*/
|
|
1784
|
+
const chosenAsDocument = choice !== undefined && picked !== OTHER_FORMAT;
|
|
1519
1785
|
const content =
|
|
1520
|
-
|
|
1786
|
+
suppliedBody ??
|
|
1787
|
+
format?.studio?.newFileTemplate ??
|
|
1788
|
+
(format
|
|
1789
|
+
? ""
|
|
1790
|
+
: fileName.toLowerCase().endsWith(".json")
|
|
1791
|
+
? chosenAsDocument
|
|
1792
|
+
? BLANK_DOCUMENT
|
|
1793
|
+
: "{}\n"
|
|
1794
|
+
: "");
|
|
1521
1795
|
try {
|
|
1522
1796
|
await getPlatform().writeFile(path, content);
|
|
1523
1797
|
await loadDirectory(dir);
|
|
@@ -1533,8 +1807,66 @@ export async function createFileIn(request: NewFileRequest): Promise<string | nu
|
|
|
1533
1807
|
}
|
|
1534
1808
|
}
|
|
1535
1809
|
|
|
1810
|
+
/**
|
|
1811
|
+
* The tree's New File, which asks the destination what it is before it asks the reader for a name.
|
|
1812
|
+
*
|
|
1813
|
+
* Four answers, and the difference between them is what feature 3 is:
|
|
1814
|
+
*
|
|
1815
|
+
* - A **content collection's own source root** routes to `content/entry-commands.ts`'s `createEntry`,
|
|
1816
|
+
* which supplies the collection's extension AND a body seeded from its schema, then opens the
|
|
1817
|
+
* entry form. Reimplementing any of that here would be a second seeder drifting from the one the
|
|
1818
|
+
* Library and the palette already use;
|
|
1819
|
+
* - A **subdirectory** of a collection, or a collection with no `schema`, is CONSTRAINED but not
|
|
1820
|
+
* rerouted. Discovery is recursive so a document there really is an entry, but co-located media
|
|
1821
|
+
* lives there too (`site-architecture.md` §6.5) and `createEntry` writes to ONE directory — so
|
|
1822
|
+
* rerouting would silently relocate the file, and seeding a schema-less collection would seed
|
|
1823
|
+
* nothing anyway;
|
|
1824
|
+
* - A collection whose declared `format` names a class the project has not registered gets the FULL
|
|
1825
|
+
* picker and a Problem naming that class. Locking to a guessed extension would be a stated lie
|
|
1826
|
+
* plus an enforced refusal, which is worse than not constraining at all;
|
|
1827
|
+
* - Anywhere else opens the full picker.
|
|
1828
|
+
*
|
|
1829
|
+
* The context-menu label does NOT change for a collection folder. The tree's own verbs are what the
|
|
1830
|
+
* TREE does, and the dialog itself names the destination.
|
|
1831
|
+
*/
|
|
1536
1832
|
async function createNewFile(dirPath: string, renderLeftPanel: () => void) {
|
|
1537
|
-
|
|
1833
|
+
await loadFormats();
|
|
1834
|
+
const collection = collectionForDirectory(dirPath);
|
|
1835
|
+
|
|
1836
|
+
if (collection?.unresolvedFormat != null) {
|
|
1837
|
+
notify.error(`New files in ${dirPath}/ cannot be constrained to the collection's format.`, {
|
|
1838
|
+
detail:
|
|
1839
|
+
`The "${collection.name}" content type declares format "${collection.unresolvedFormat}", ` +
|
|
1840
|
+
"which no installed extension provides. Install the extension that supplies it, or correct " +
|
|
1841
|
+
"the name in Project Settings › Content Types.",
|
|
1842
|
+
path: dirPath,
|
|
1843
|
+
source: "Files",
|
|
1844
|
+
});
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
const constrained = collection?.ext != null && collection.unresolvedFormat === null;
|
|
1848
|
+
const seedable = constrained && collection.isSourceRoot && collection.def.schema !== undefined;
|
|
1849
|
+
|
|
1850
|
+
let created: string | null;
|
|
1851
|
+
if (seedable) {
|
|
1852
|
+
const { createEntry } = await import("../content/entry-commands");
|
|
1853
|
+
created = await createEntry(collection.name, { dir: dirPath });
|
|
1854
|
+
} else {
|
|
1855
|
+
created = await createFileIn({
|
|
1856
|
+
dir: dirPath,
|
|
1857
|
+
format:
|
|
1858
|
+
constrained && collection.ext !== null
|
|
1859
|
+
? {
|
|
1860
|
+
because:
|
|
1861
|
+
`${collection.name} entries are ${collection.ext} files, so a document of another ` +
|
|
1862
|
+
"format here would not be one.",
|
|
1863
|
+
ext: collection.ext,
|
|
1864
|
+
kind: "locked",
|
|
1865
|
+
}
|
|
1866
|
+
: { defaultExt: ".json", kind: "choose" },
|
|
1867
|
+
suggestedName: "untitled",
|
|
1868
|
+
});
|
|
1869
|
+
}
|
|
1538
1870
|
if (created !== null) {
|
|
1539
1871
|
renderLeftPanel();
|
|
1540
1872
|
}
|
|
@@ -1573,6 +1905,38 @@ function renameStatus(newName: string, report: RenameResult): string {
|
|
|
1573
1905
|
return `Renamed to ${newName}`;
|
|
1574
1906
|
}
|
|
1575
1907
|
|
|
1908
|
+
/**
|
|
1909
|
+
* Report a move whose refactor pass could not finish, instead of reporting a plain success.
|
|
1910
|
+
*
|
|
1911
|
+
* The dialog above a rename states, in a modal the user has to accept, that N references "will be
|
|
1912
|
+
* updated automatically. Nothing else changes." The engine keeps that promise for every document it
|
|
1913
|
+
* can write — but some it cannot: a content source like a `.csv` collection has a parser and
|
|
1914
|
+
* deliberately no serializer, and a document that fails to parse cannot be rewritten either. In
|
|
1915
|
+
* both cases `applyRename` names the file rather than dropping it, and this is the half that makes
|
|
1916
|
+
* the naming reach a person. Silence here is what turns a stated promise into a false one.
|
|
1917
|
+
*
|
|
1918
|
+
* A drag-move makes no promise at all — it has no dialog — which is exactly why it needs this more,
|
|
1919
|
+
* not less. A format CONVERSION makes the strongest promise of the three, and shares this for that
|
|
1920
|
+
* reason: it is a rename whose dialog also states what the file is becoming.
|
|
1921
|
+
*
|
|
1922
|
+
* @param {string} headline — what happened, as the success case would have put it.
|
|
1923
|
+
* @param {RenameResult} report
|
|
1924
|
+
* @param {string} path — the moved file, so Problems can click through.
|
|
1925
|
+
*/
|
|
1926
|
+
export function notifyMoveOutcome(headline: string, report: RenameResult, path: string): void {
|
|
1927
|
+
const stuck = report.errors ?? [];
|
|
1928
|
+
if (stuck.length === 0) {
|
|
1929
|
+
notify.success(headline);
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
const files = stuck.length === 1 ? "1 file" : `${stuck.length} files`;
|
|
1933
|
+
notify.warn(`${headline} — references in ${files} could not be updated`, {
|
|
1934
|
+
detail: stuck.map((e) => `${e.path}: ${e.error}`).join("\n"),
|
|
1935
|
+
path,
|
|
1936
|
+
source: "Files",
|
|
1937
|
+
});
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1576
1940
|
/** Reload any open tabs whose references the refactor rewrote (so the editor shows new paths). */
|
|
1577
1941
|
function reloadRewrittenTabs(report: RenameResult, skipPath: string): void {
|
|
1578
1942
|
for (const f of report.references?.files ?? []) {
|
|
@@ -1582,6 +1946,42 @@ function reloadRewrittenTabs(report: RenameResult, skipPath: string): void {
|
|
|
1582
1946
|
}
|
|
1583
1947
|
}
|
|
1584
1948
|
|
|
1949
|
+
/** The directory holding a path, project-relative. `"."` for a file at the project root. */
|
|
1950
|
+
export function parentDirOf(path: string): string {
|
|
1951
|
+
const normalized = path.replaceAll("\\", "/");
|
|
1952
|
+
return normalized.includes("/") ? normalized.slice(0, normalized.lastIndexOf("/")) : ".";
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
/**
|
|
1956
|
+
* Everything the app has to catch up on once a file has MOVED — shared by the rename and by the
|
|
1957
|
+
* format conversion, which is a rename with the bytes rewritten on the way.
|
|
1958
|
+
*
|
|
1959
|
+
* It is one function because the two must not drift: the refactor pass has just rewritten
|
|
1960
|
+
* references project-wide, `markLocalMutation` has suppressed the watcher echo that would otherwise
|
|
1961
|
+
* say so, and every consequence of that has to be replayed by hand. A converter that forgot
|
|
1962
|
+
* `invalidateUsages` would leave the next delete dialog quoting a count from before the move.
|
|
1963
|
+
*
|
|
1964
|
+
* The tab is NOT reopened here. A rename keeps its document and only re-keys the tab; a conversion
|
|
1965
|
+
* has to rebuild it, because the format changed. That difference belongs to the callers.
|
|
1966
|
+
*
|
|
1967
|
+
* @param from - The path before the move.
|
|
1968
|
+
* @param to - The path after it.
|
|
1969
|
+
* @param report - What the backend's refactor pass rewrote.
|
|
1970
|
+
*/
|
|
1971
|
+
export async function settleRename(from: string, to: string, report: RenameResult): Promise<void> {
|
|
1972
|
+
invalidateUsages();
|
|
1973
|
+
const fromDir = parentDirOf(from);
|
|
1974
|
+
const toDir = parentDirOf(to);
|
|
1975
|
+
await loadDirectory(fromDir);
|
|
1976
|
+
if (toDir !== fromDir) {
|
|
1977
|
+
await loadDirectory(toDir);
|
|
1978
|
+
}
|
|
1979
|
+
if (requireProjectState().selectedPath === from) {
|
|
1980
|
+
requireProjectState().selectedPath = to;
|
|
1981
|
+
}
|
|
1982
|
+
reloadRewrittenTabs(report, to);
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1585
1985
|
async function renameFile(
|
|
1586
1986
|
entry: { name: string; path: string; type: string },
|
|
1587
1987
|
renderLeftPanel: () => void,
|
|
@@ -1590,28 +1990,18 @@ async function renameFile(
|
|
|
1590
1990
|
if (!newName || newName === entry.name) {
|
|
1591
1991
|
return;
|
|
1592
1992
|
}
|
|
1593
|
-
const
|
|
1594
|
-
const parentDirPath = entryPath.includes("/")
|
|
1595
|
-
? entryPath.slice(0, entryPath.lastIndexOf("/"))
|
|
1596
|
-
: ".";
|
|
1993
|
+
const parentDirPath = parentDirOf(entry.path);
|
|
1597
1994
|
const newPath = parentDirPath === "." ? newName : `${parentDirPath}/${newName}`;
|
|
1598
1995
|
markLocalMutation(entry.path, newPath);
|
|
1599
1996
|
try {
|
|
1600
1997
|
const platform = getPlatform();
|
|
1601
1998
|
const report = await platform.renameFile(entry.path, newPath);
|
|
1602
|
-
// The refactor pass just rewrote references project-wide, and `markLocalMutation` suppresses
|
|
1603
|
-
// The watcher echo that would otherwise say so — hence the explicit drop.
|
|
1604
|
-
invalidateUsages();
|
|
1605
|
-
await loadDirectory(parentDirPath);
|
|
1606
|
-
if (requireProjectState().selectedPath === entry.path) {
|
|
1607
|
-
requireProjectState().selectedPath = newPath;
|
|
1608
|
-
}
|
|
1609
1999
|
if (workspace.tabs.has(entry.path)) {
|
|
1610
2000
|
renameTab(entry.path, newPath, newPath);
|
|
1611
2001
|
}
|
|
1612
|
-
|
|
2002
|
+
await settleRename(entry.path, newPath, report);
|
|
1613
2003
|
renderLeftPanel();
|
|
1614
|
-
|
|
2004
|
+
notifyMoveOutcome(renameStatus(newName, report), report, newPath);
|
|
1615
2005
|
} catch (error) {
|
|
1616
2006
|
notify.error(`Could not rename ${entry.name}.`, {
|
|
1617
2007
|
detail: errorMessage(error),
|
|
@@ -1716,6 +2106,29 @@ export async function openFileInTab(path: string, opts: OpenFileOpts = {}) {
|
|
|
1716
2106
|
}
|
|
1717
2107
|
}
|
|
1718
2108
|
|
|
2109
|
+
/*
|
|
2110
|
+
* Media opens in the viewer, and the branch has to be HERE.
|
|
2111
|
+
*
|
|
2112
|
+
* Everything below reads the file as text: a PNG went through `platform.readFile`, matched no
|
|
2113
|
+
* format class and no `.json`, and threw — so clicking an image in the tree produced "No format
|
|
2114
|
+
* class imported for … — add one to project.json imports", which is not advice about a PNG. The
|
|
2115
|
+
* Library's tiles and the file context menu's Open route through this same function, so they were
|
|
2116
|
+
* all the same dead end, and `workspace/session.ts` restores tabs by calling it again — which is
|
|
2117
|
+
* why a branch anywhere else would send a restored media tab straight back down the error path.
|
|
2118
|
+
*/
|
|
2119
|
+
if (isViewableMedia(path)) {
|
|
2120
|
+
openMediaTab(path);
|
|
2121
|
+
if (follows) {
|
|
2122
|
+
requireProjectState().selectedPath = path;
|
|
2123
|
+
}
|
|
2124
|
+
trackRecentFile({
|
|
2125
|
+
name: path.split("/").pop() || path,
|
|
2126
|
+
path,
|
|
2127
|
+
root: requireProjectState().projectRoot,
|
|
2128
|
+
});
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
|
|
1719
2132
|
// CSV files open in the grid editor (source mode remains as the raw-text alternate).
|
|
1720
2133
|
if (path.toLowerCase().endsWith(".csv")) {
|
|
1721
2134
|
try {
|
|
@@ -1740,6 +2153,19 @@ export async function openFileInTab(path: string, opts: OpenFileOpts = {}) {
|
|
|
1740
2153
|
try {
|
|
1741
2154
|
const content = await platform.readFile(path);
|
|
1742
2155
|
if (!content) {
|
|
2156
|
+
/*
|
|
2157
|
+
* An empty file still has to answer for itself.
|
|
2158
|
+
*
|
|
2159
|
+
* Returning silently was defensible while every file the tree could create was a seeded
|
|
2160
|
+
* document. The format picker's "Other…" row makes an empty `main.css` or `.gitignore` an
|
|
2161
|
+
* ordinary thing to create, and clicking one and having NOTHING happen — no tab, no error, no
|
|
2162
|
+
* toast — reads as a broken tree rather than as a file Studio has no editor for. An empty
|
|
2163
|
+
* file the studio CAN open (a `.json`, or one a format claims) is still nothing to report.
|
|
2164
|
+
*/
|
|
2165
|
+
await loadFormats();
|
|
2166
|
+
if (!formatForPath(path) && !path.toLowerCase().endsWith(".json")) {
|
|
2167
|
+
throw noFormatError(path);
|
|
2168
|
+
}
|
|
1743
2169
|
return;
|
|
1744
2170
|
}
|
|
1745
2171
|
|