@jxsuite/studio 3.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/README.md +5 -5
  2. package/dist/chunks/{2020-xp8vnwma.js → 2020-n6tfwnda.js} +2 -2
  3. package/dist/chunks/Overlay.dev-etqjwyf9.js +23 -0
  4. package/dist/chunks/cf-account-picker-en86v7x7.js +163 -0
  5. package/dist/chunks/{cf-settings-zj2cj1ar.js → cf-settings-45fgyx8y.js} +6 -5
  6. package/dist/chunks/{components-z6cnkyy1.js → components-zmx10t62.js} +6 -5
  7. package/dist/chunks/content-source-48xg86wj.js +49 -0
  8. package/dist/chunks/{convert-to-repeater-6vvx96n2.js → convert-to-repeater-43tfn7cs.js} +33 -25
  9. package/dist/chunks/entry-commands-e63f1xc0.js +46 -0
  10. package/dist/chunks/{extension-sections-kwzvpemr.js → extension-sections-txv08xxg.js} +34 -26
  11. package/dist/chunks/file-ops-wbh4hhsx.js +48 -0
  12. package/dist/chunks/files-3kfkwhk4.js +93 -0
  13. package/dist/chunks/{focus-trap.esm-ggawf1hy.js → focus-trap.esm-9t2drp0g.js} +6 -5
  14. package/dist/chunks/{format-host-8fr3545e.js → format-host-w16q81tf.js} +3 -3
  15. package/dist/chunks/fs-events-hrrg328g.js +21 -0
  16. package/dist/chunks/{git-panel-g01d93vc.js → git-panel-532whr1y.js} +22 -16
  17. package/dist/chunks/{index-nj44jpzj.js → index-wcnt3r9g.js} +2 -2
  18. package/dist/chunks/{jx-validate-jj9rnt4n.js → jx-validate-8er6gbxy.js} +6 -4
  19. package/dist/chunks/layers-panel-07hm7g5j.js +67 -0
  20. package/dist/chunks/layers-q75tj2mf.js +45 -0
  21. package/dist/chunks/lit-html-j055mkh8.js +22 -0
  22. package/dist/chunks/monaco-buffer-dqw0xxpx.js +25 -0
  23. package/dist/chunks/{monaco-setup-a5wkmbgh.js → monaco-setup-vx9bhp1d.js} +4 -4
  24. package/dist/chunks/notify-yjm79n4s.js +30 -0
  25. package/dist/chunks/parse-24d0gay3.js +19 -0
  26. package/dist/chunks/platform-ba1j1gbh.js +16 -0
  27. package/dist/chunks/publish-panel-v9st96ja.js +29 -0
  28. package/dist/chunks/quick-search-zccjp6gv.js +66 -0
  29. package/dist/chunks/references-3qx8fkyp.js +29 -0
  30. package/dist/chunks/right-panel-vm3b6kwy.js +46 -0
  31. package/dist/chunks/sp-overlay.dev-qgvhnxf4.js +18 -0
  32. package/dist/chunks/sp-popover.dev-n2etmqdp.js +9 -0
  33. package/dist/chunks/sp-progress-circle.dev-hryhj0es.js +15 -0
  34. package/dist/chunks/sp-tooltip.dev-4sastdnw.js +15 -0
  35. package/dist/chunks/{sp-tray.dev-64v4pxw8.js → sp-tray.dev-tkbscm06.js} +5 -5
  36. package/dist/chunks/{studio-wdt9y92a.js → studio-0bq6eh7v.js} +2 -2
  37. package/dist/chunks/studio-1ngbejft.js +181 -0
  38. package/dist/chunks/studio-1wagb550.js +118 -0
  39. package/dist/chunks/{studio-j595e748.js → studio-2b946z26.js} +172 -95
  40. package/dist/chunks/{studio-pkp61mh8.js → studio-3bqpr0zh.js} +25 -17
  41. package/dist/chunks/{studio-k4jvjcfx.js → studio-4b5z1x43.js} +46 -12
  42. package/dist/chunks/{studio-ry1vz5sv.js → studio-4n2b5gzb.js} +15 -13
  43. package/dist/chunks/{studio-p5dja96s.js → studio-4r1pv6zf.js} +2 -2
  44. package/dist/chunks/{studio-kvfx9z2c.js → studio-4rzyrtjz.js} +70 -31
  45. package/dist/chunks/{studio-tzx3xmvx.js → studio-5fx3g9jg.js} +33 -52
  46. package/dist/chunks/{studio-rh1cnamv.js → studio-5myknm0a.js} +565 -135
  47. package/dist/chunks/studio-6yw4y3my.js +91 -0
  48. package/dist/chunks/{studio-0rqqhrdy.js → studio-7nte2hj4.js} +2 -2
  49. package/dist/chunks/studio-92ab2jqe.js +282 -0
  50. package/dist/chunks/{studio-6z5eyjwn.js → studio-950k0dx6.js} +72 -135
  51. package/dist/chunks/{studio-sas7mt3k.js → studio-9ab8gqsm.js} +2 -2
  52. package/dist/chunks/studio-9n4mrmnb.js +184 -0
  53. package/dist/chunks/{studio-hcxzyk3x.js → studio-ahn3pdgx.js} +6 -6
  54. package/dist/chunks/studio-b55y6ewx.js +498 -0
  55. package/dist/chunks/{studio-s519zc99.js → studio-bgxt93ga.js} +3 -3
  56. package/dist/chunks/studio-cft9r4r7.js +119 -0
  57. package/dist/chunks/{studio-dc8s8heb.js → studio-cmb03bg5.js} +6904 -8321
  58. package/dist/chunks/studio-dm19q43r.js +195 -0
  59. package/dist/chunks/{studio-2x9zbxmx.js → studio-ef49pnzx.js} +11 -9
  60. package/dist/chunks/{studio-ay8wcr2b.js → studio-eqq1w638.js} +7 -9
  61. package/dist/chunks/{studio-5efrtn1m.js → studio-ew0djmhg.js} +23 -19
  62. package/dist/chunks/{studio-tr20m8g8.js → studio-f3wqjtk5.js} +39 -3
  63. package/dist/chunks/{studio-xntfdy1x.js → studio-fbf92ckf.js} +36 -23
  64. package/dist/chunks/{studio-4q0wznzf.js → studio-gcye7qka.js} +442 -126
  65. package/dist/chunks/{studio-7p35254k.js → studio-gfgb8pe6.js} +36 -193
  66. package/dist/chunks/studio-jc994dnr.js +1066 -0
  67. package/dist/chunks/{studio-hj2se583.js → studio-jkftxyvz.js} +69 -3423
  68. package/dist/chunks/{studio-5x5774ak.js → studio-kryvsw6j.js} +3 -3
  69. package/dist/chunks/{studio-kfpek536.js → studio-kyh9avvv.js} +2 -2
  70. package/dist/chunks/{studio-twsafr1e.js → studio-m3hwn7y1.js} +3 -3
  71. package/dist/chunks/studio-msd847zn.js +1315 -0
  72. package/dist/chunks/{studio-g5gdqnyd.js → studio-mtfnaqs9.js} +19 -19
  73. package/dist/chunks/{studio-7ejemhej.js → studio-n6j9t10q.js} +6 -6
  74. package/dist/chunks/{studio-vhk5fmfv.js → studio-n8b9wqwy.js} +5 -5
  75. package/dist/chunks/{studio-cjm7fdjf.js → studio-p4ngc046.js} +8 -104
  76. package/dist/chunks/studio-q43a5y9h.js +394 -0
  77. package/dist/chunks/studio-q7v10cg2.js +106 -0
  78. package/dist/chunks/{studio-6kv3apa9.js → studio-q9jd1a60.js} +27 -11
  79. package/dist/chunks/studio-qcy05dp8.js +1743 -0
  80. package/dist/chunks/{studio-a0yp7syn.js → studio-qjy2z9bv.js} +29 -132
  81. package/dist/chunks/{studio-2w11grav.js → studio-qq6jym04.js} +3 -3
  82. package/dist/chunks/{studio-3fk1ac9z.js → studio-rdx8jcxy.js} +4 -4
  83. package/dist/chunks/{studio-4mqb9a0j.js → studio-rn9ta2mz.js} +4 -4
  84. package/dist/chunks/studio-t7qb9db8.js +665 -0
  85. package/dist/chunks/studio-tmc5emrw.js +8 -0
  86. package/dist/chunks/{studio-r5xbtctf.js → studio-trxrabqf.js} +292 -2297
  87. package/dist/chunks/studio-wcy8trfx.js +263 -0
  88. package/dist/chunks/{studio-3dkcd4c5.js → studio-x4qkdat5.js} +62 -57
  89. package/dist/chunks/studio-x813zp48.js +195 -0
  90. package/dist/chunks/{studio-qpfq7xhm.js → studio-xrapxwwc.js} +22 -285
  91. package/dist/chunks/{studio-xm3dbxvn.js → studio-zczps70q.js} +2 -2
  92. package/dist/chunks/{studio-8ezdegcx.js → studio-zqazsct2.js} +40 -36
  93. package/dist/chunks/workspace-fjx52ryv.js +89 -0
  94. package/dist/iframe-entry.js +141 -56
  95. package/dist/manifest.json +7 -0
  96. package/dist/studio.js +2605 -1950
  97. package/favicon.ico +0 -0
  98. package/index.html +1 -0
  99. package/package.json +20 -18
  100. package/src/about/about-modal.ts +4 -3
  101. package/src/browse/library-pane.ts +57 -3
  102. package/src/canvas/canvas-render.ts +23 -1
  103. package/src/canvas/iframe-patch.ts +47 -21
  104. package/src/canvas/iframe-render.ts +22 -6
  105. package/src/commands/app-commands.ts +4 -0
  106. package/src/commands/command-args.ts +29 -0
  107. package/src/commands/context.ts +15 -1
  108. package/src/commands/defaults.ts +29 -1
  109. package/src/commands/levels.ts +16 -0
  110. package/src/commands/live-context.ts +3 -0
  111. package/src/content/collection-match.ts +267 -0
  112. package/src/content/entry-commands.ts +38 -29
  113. package/src/content/entry-fields.ts +6 -0
  114. package/src/editor/shortcuts.ts +5 -0
  115. package/src/files/file-ops.ts +10 -42
  116. package/src/files/files.ts +487 -59
  117. package/src/files/fs-events.ts +9 -0
  118. package/src/files/gitignore.ts +432 -0
  119. package/src/files/media-paths.ts +13 -42
  120. package/src/files/media-usage.ts +41 -131
  121. package/src/files/serialize-document.ts +60 -0
  122. package/src/format/convert-file.ts +511 -0
  123. package/src/format/format-choices.ts +199 -0
  124. package/src/grid/grid-view.ts +16 -2
  125. package/src/hosting/document.ts +2 -0
  126. package/src/hosting/layout.ts +8 -0
  127. package/src/media/media-open.ts +58 -0
  128. package/src/media/media-pane.ts +310 -0
  129. package/src/new-project/import-tab.ts +357 -163
  130. package/src/new-project/location-fields.ts +8 -5
  131. package/src/new-project/new-project-modal.ts +27 -34
  132. package/src/packages/ensure-deps.ts +14 -7
  133. package/src/packages/jxsuite-update.ts +11 -11
  134. package/src/page-params.ts +10 -46
  135. package/src/panels/activity-bar.ts +45 -16
  136. package/src/panels/ai-chat/attached-context.ts +6 -1
  137. package/src/panels/ai-chat/chat-view.ts +255 -13
  138. package/src/panels/ai-chat/composer.ts +31 -97
  139. package/src/panels/ai-panel.ts +116 -2
  140. package/src/panels/navigator-panels.ts +7 -6
  141. package/src/panels/settings-menu.ts +799 -0
  142. package/src/panels/toolbar.ts +135 -15
  143. package/src/platforms/cloud.ts +261 -70
  144. package/src/platforms/devserver.ts +67 -33
  145. package/src/preview/preview-overlay.ts +226 -0
  146. package/src/publish/publish-panel.ts +62 -2
  147. package/src/services/ai-ask.ts +247 -0
  148. package/src/services/ai-import-tools.ts +532 -0
  149. package/src/services/ai-models.ts +108 -1
  150. package/src/services/ai-project-tools.ts +20 -26
  151. package/src/services/ai-system-prompt.ts +88 -2
  152. package/src/services/ai-turn-signal.ts +70 -0
  153. package/src/services/connection.ts +74 -0
  154. package/src/services/context-manager.ts +101 -1
  155. package/src/services/document-assistant.ts +45 -4
  156. package/src/services/import-client.ts +33 -6
  157. package/src/services/import-run.ts +158 -0
  158. package/src/services/import-seed.ts +120 -0
  159. package/src/services/jx-validate.ts +58 -6
  160. package/src/services/project-adoption.ts +71 -0
  161. package/src/services/references.ts +0 -0
  162. package/src/services/settings/definitions.ts +17 -0
  163. package/src/services/tool-executor.ts +45 -2
  164. package/src/settings/contexts-section.ts +45 -3
  165. package/src/settings/dependencies-editor.ts +43 -23
  166. package/src/settings/general-settings.ts +30 -12
  167. package/src/settings/preferences-accounts.ts +193 -12
  168. package/src/settings/preferences-dialog.ts +93 -59
  169. package/src/settings/preferences-sections.ts +57 -0
  170. package/src/settings/settings-document.ts +65 -9
  171. package/src/studio.ts +33 -6
  172. package/src/tabs/transact.ts +42 -19
  173. package/src/types.ts +129 -7
  174. package/src/ui/ai-managed-connect.ts +62 -13
  175. package/src/ui/ai-model-picker.ts +189 -0
  176. package/src/ui/cf-account-picker.ts +200 -0
  177. package/src/ui/choice-field.ts +72 -0
  178. package/src/ui/layers.ts +83 -5
  179. package/src/utils/studio-utils.ts +22 -52
  180. package/styles/forced-colors.css +7 -0
  181. package/styles/overlays.css +256 -0
  182. package/styles/panels.css +13 -1
  183. package/styles/shell.css +147 -0
  184. package/dist/chunks/Overlay.dev-d2ajy6sj.js +0 -23
  185. package/dist/chunks/content-source-7fy9qrm0.js +0 -40
  186. package/dist/chunks/files-xcybanxe.js +0 -70
  187. package/dist/chunks/layers-panel-27essgkc.js +0 -60
  188. package/dist/chunks/publish-panel-09sy2bab.js +0 -22
  189. package/dist/chunks/quick-search-sefb61v8.js +0 -55
  190. package/dist/chunks/right-panel-9r32jd5j.js +0 -39
  191. package/dist/chunks/sp-overlay.dev-s18hymn9.js +0 -18
  192. package/dist/chunks/sp-popover.dev-dj9x7e7a.js +0 -9
  193. package/dist/chunks/sp-progress-circle.dev-0zxcyz3r.js +0 -15
  194. package/dist/chunks/sp-tooltip.dev-5c6d1kjz.js +0 -15
  195. package/dist/chunks/studio-bw5dz6km.js +0 -608
  196. package/dist/chunks/studio-c31yb4t6.js +0 -55
  197. package/src/canvas/site-style-css.ts +0 -117
@@ -29,12 +29,16 @@ 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
43
  import { isImage, uploadAccept, uploadAssets } from "./media-upload";
40
44
  import { isCollabPath } from "../collab/collab-state";
@@ -56,19 +60,22 @@ import {
56
60
  workspace,
57
61
  } from "../workspace/workspace";
58
62
  import { openCsvGridTab, openPagesGrid } from "../grid/grid-open";
59
- import { collectionDirs } from "../grid/sources/content-source";
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
- const entries = await platform.listDirectory(dirPath);
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
- ? sorted.filter((e) => e.type === "directory" || e.name.toLowerCase().includes(query))
612
- : sorted;
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";
@@ -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
- notify.success(`Moved to ${newPath}`);
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
- const collection = collectionDirs().find(
1257
- ({ dir }) => entry.path === dir || entry.path.endsWith(`/${dir}`),
1258
- );
1259
- if (collection) {
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
- } else if (collectionOfPath(entry.path)) {
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; its stem is selected so typing replaces the name and keeps the extension. */
1561
+ /** Pre-filled value. In picker modes it is a STEM; the picker supplies the extension. */
1443
1562
  suggestedName?: string;
1444
1563
  /**
1445
- * When set, the field asks for a DISPLAY NAME and this extension is appended to its slug — "My
1446
- * First Post" becomes `my-first-post.md`. When absent, the field asks for a file name and takes
1447
- * it verbatim, which is what the Files tree has always done.
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
- ext?: string;
1450
- /** Body to write. Defaults to the resolved format's `newFileTemplate`. */
1451
- content?: string;
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
- * Create one file, from the one flow both the Files tree and the Library use.
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
- * Two behaviours the callers used to disagree about, settled here:
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, ext, source = "Files" } = request;
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 fileNameFor = (input: string) =>
1486
- ext === undefined
1487
- ? input.trim()
1488
- : `${input
1489
- .trim()
1490
- .toLowerCase()
1491
- .replaceAll(/\s+/g, "-")
1492
- .replaceAll(/[^a-z\d-]/g, "")}${ext}`;
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
- select: "stem",
1499
- validate: (value) => {
1500
- if (!value.trim()) {
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
- request.content ?? format?.studio?.newFileTemplate ?? (format ? "" : BLANK_DOCUMENT);
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
- const created = await createFileIn({ dir: dirPath, suggestedName: "untitled.json" });
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,40 @@ 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, and it can write more than it round-trips: a `.csv` collection has a parser and
1914
+ * deliberately no serializer, but declares the narrower `rewrite` capability, so a reference in one
1915
+ * is repaired cell by cell. What is left over is a document that fails to parse, and a tag rename
1916
+ * inside a format with no serializer. In those `applyRename` names the file rather than dropping
1917
+ * it, and this is the half that makes the naming reach a person. Silence here is what turns a
1918
+ * stated promise into a false one.
1919
+ *
1920
+ * A drag-move makes no promise at all — it has no dialog — which is exactly why it needs this more,
1921
+ * not less. A format CONVERSION makes the strongest promise of the three, and shares this for that
1922
+ * reason: it is a rename whose dialog also states what the file is becoming.
1923
+ *
1924
+ * @param {string} headline — what happened, as the success case would have put it.
1925
+ * @param {RenameResult} report
1926
+ * @param {string} path — the moved file, so Problems can click through.
1927
+ */
1928
+ export function notifyMoveOutcome(headline: string, report: RenameResult, path: string): void {
1929
+ const stuck = report.errors ?? [];
1930
+ if (stuck.length === 0) {
1931
+ notify.success(headline);
1932
+ return;
1933
+ }
1934
+ const files = stuck.length === 1 ? "1 file" : `${stuck.length} files`;
1935
+ notify.warn(`${headline} — references in ${files} could not be updated`, {
1936
+ detail: stuck.map((e) => `${e.path}: ${e.error}`).join("\n"),
1937
+ path,
1938
+ source: "Files",
1939
+ });
1940
+ }
1941
+
1576
1942
  /** Reload any open tabs whose references the refactor rewrote (so the editor shows new paths). */
1577
1943
  function reloadRewrittenTabs(report: RenameResult, skipPath: string): void {
1578
1944
  for (const f of report.references?.files ?? []) {
@@ -1582,6 +1948,42 @@ function reloadRewrittenTabs(report: RenameResult, skipPath: string): void {
1582
1948
  }
1583
1949
  }
1584
1950
 
1951
+ /** The directory holding a path, project-relative. `"."` for a file at the project root. */
1952
+ export function parentDirOf(path: string): string {
1953
+ const normalized = path.replaceAll("\\", "/");
1954
+ return normalized.includes("/") ? normalized.slice(0, normalized.lastIndexOf("/")) : ".";
1955
+ }
1956
+
1957
+ /**
1958
+ * Everything the app has to catch up on once a file has MOVED — shared by the rename and by the
1959
+ * format conversion, which is a rename with the bytes rewritten on the way.
1960
+ *
1961
+ * It is one function because the two must not drift: the refactor pass has just rewritten
1962
+ * references project-wide, `markLocalMutation` has suppressed the watcher echo that would otherwise
1963
+ * say so, and every consequence of that has to be replayed by hand. A converter that forgot
1964
+ * `invalidateUsages` would leave the next delete dialog quoting a count from before the move.
1965
+ *
1966
+ * The tab is NOT reopened here. A rename keeps its document and only re-keys the tab; a conversion
1967
+ * has to rebuild it, because the format changed. That difference belongs to the callers.
1968
+ *
1969
+ * @param from - The path before the move.
1970
+ * @param to - The path after it.
1971
+ * @param report - What the backend's refactor pass rewrote.
1972
+ */
1973
+ export async function settleRename(from: string, to: string, report: RenameResult): Promise<void> {
1974
+ invalidateUsages();
1975
+ const fromDir = parentDirOf(from);
1976
+ const toDir = parentDirOf(to);
1977
+ await loadDirectory(fromDir);
1978
+ if (toDir !== fromDir) {
1979
+ await loadDirectory(toDir);
1980
+ }
1981
+ if (requireProjectState().selectedPath === from) {
1982
+ requireProjectState().selectedPath = to;
1983
+ }
1984
+ reloadRewrittenTabs(report, to);
1985
+ }
1986
+
1585
1987
  async function renameFile(
1586
1988
  entry: { name: string; path: string; type: string },
1587
1989
  renderLeftPanel: () => void,
@@ -1590,28 +1992,18 @@ async function renameFile(
1590
1992
  if (!newName || newName === entry.name) {
1591
1993
  return;
1592
1994
  }
1593
- const entryPath = entry.path.replaceAll("\\", "/");
1594
- const parentDirPath = entryPath.includes("/")
1595
- ? entryPath.slice(0, entryPath.lastIndexOf("/"))
1596
- : ".";
1995
+ const parentDirPath = parentDirOf(entry.path);
1597
1996
  const newPath = parentDirPath === "." ? newName : `${parentDirPath}/${newName}`;
1598
1997
  markLocalMutation(entry.path, newPath);
1599
1998
  try {
1600
1999
  const platform = getPlatform();
1601
2000
  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
2001
  if (workspace.tabs.has(entry.path)) {
1610
2002
  renameTab(entry.path, newPath, newPath);
1611
2003
  }
1612
- reloadRewrittenTabs(report, newPath);
2004
+ await settleRename(entry.path, newPath, report);
1613
2005
  renderLeftPanel();
1614
- notify.success(renameStatus(newName, report));
2006
+ notifyMoveOutcome(renameStatus(newName, report), report, newPath);
1615
2007
  } catch (error) {
1616
2008
  notify.error(`Could not rename ${entry.name}.`, {
1617
2009
  detail: errorMessage(error),
@@ -1716,6 +2108,29 @@ export async function openFileInTab(path: string, opts: OpenFileOpts = {}) {
1716
2108
  }
1717
2109
  }
1718
2110
 
2111
+ /*
2112
+ * Media opens in the viewer, and the branch has to be HERE.
2113
+ *
2114
+ * Everything below reads the file as text: a PNG went through `platform.readFile`, matched no
2115
+ * format class and no `.json`, and threw — so clicking an image in the tree produced "No format
2116
+ * class imported for … — add one to project.json imports", which is not advice about a PNG. The
2117
+ * Library's tiles and the file context menu's Open route through this same function, so they were
2118
+ * all the same dead end, and `workspace/session.ts` restores tabs by calling it again — which is
2119
+ * why a branch anywhere else would send a restored media tab straight back down the error path.
2120
+ */
2121
+ if (isViewableMedia(path)) {
2122
+ openMediaTab(path);
2123
+ if (follows) {
2124
+ requireProjectState().selectedPath = path;
2125
+ }
2126
+ trackRecentFile({
2127
+ name: path.split("/").pop() || path,
2128
+ path,
2129
+ root: requireProjectState().projectRoot,
2130
+ });
2131
+ return;
2132
+ }
2133
+
1719
2134
  // CSV files open in the grid editor (source mode remains as the raw-text alternate).
1720
2135
  if (path.toLowerCase().endsWith(".csv")) {
1721
2136
  try {
@@ -1740,6 +2155,19 @@ export async function openFileInTab(path: string, opts: OpenFileOpts = {}) {
1740
2155
  try {
1741
2156
  const content = await platform.readFile(path);
1742
2157
  if (!content) {
2158
+ /*
2159
+ * An empty file still has to answer for itself.
2160
+ *
2161
+ * Returning silently was defensible while every file the tree could create was a seeded
2162
+ * document. The format picker's "Other…" row makes an empty `main.css` or `.gitignore` an
2163
+ * ordinary thing to create, and clicking one and having NOTHING happen — no tab, no error, no
2164
+ * toast — reads as a broken tree rather than as a file Studio has no editor for. An empty
2165
+ * file the studio CAN open (a `.json`, or one a format claims) is still nothing to report.
2166
+ */
2167
+ await loadFormats();
2168
+ if (!formatForPath(path) && !path.toLowerCase().endsWith(".json")) {
2169
+ throw noFormatError(path);
2170
+ }
1743
2171
  return;
1744
2172
  }
1745
2173