@jxsuite/studio 2.0.1 → 2.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 (419) hide show
  1. package/canvas.html +21 -0
  2. package/dist/chunks/Overlay.dev-qfjzkms8.js +23 -0
  3. package/dist/chunks/{Overlay.dev-cszx7yn5.js.map → Overlay.dev-qfjzkms8.js.map} +1 -1
  4. package/dist/chunks/{adapter-z8fkmbmv.js → adapter-qfnesn5v.js} +2 -2
  5. package/dist/chunks/{cf-settings-mmyv436d.js → cf-settings-73sh5z4p.js} +3 -3
  6. package/dist/chunks/{components-799jpvhm.js → components-2bpqp4mq.js} +5 -7
  7. package/dist/chunks/{components-799jpvhm.js.map → components-2bpqp4mq.js.map} +1 -1
  8. package/dist/chunks/content-source-34gnkbnn.js +38 -0
  9. package/dist/chunks/content-source-34gnkbnn.js.map +9 -0
  10. package/dist/chunks/convert-to-repeater-t8kxre5s.js +258 -0
  11. package/dist/chunks/convert-to-repeater-t8kxre5s.js.map +10 -0
  12. package/dist/chunks/extension-sections-6mqydz35.js +301 -0
  13. package/dist/chunks/extension-sections-6mqydz35.js.map +11 -0
  14. package/dist/chunks/files-8kn321h1.js +70 -0
  15. package/dist/chunks/files-8kn321h1.js.map +9 -0
  16. package/dist/chunks/{format-host-8jxwts2p.js → format-host-mkwsmt8d.js} +3 -3
  17. package/dist/chunks/git-panel-tjbr191d.js +51 -0
  18. package/dist/chunks/git-panel-tjbr191d.js.map +9 -0
  19. package/dist/chunks/{index-baeg2x3y.js → index-7pxchcce.js} +4 -3
  20. package/dist/chunks/{index-baeg2x3y.js.map → index-7pxchcce.js.map} +3 -3
  21. package/dist/chunks/{jx-validate-qx8degj7.js → jx-validate-v6qgknrs.js} +3 -3
  22. package/dist/chunks/layers-panel-87pb25km.js +58 -0
  23. package/dist/chunks/layers-panel-87pb25km.js.map +9 -0
  24. package/dist/chunks/{monaco-setup-zjdp9zdw.js → monaco-setup-e0yh25j4.js} +4 -75
  25. package/dist/chunks/{monaco-setup-zjdp9zdw.js.map → monaco-setup-e0yh25j4.js.map} +3 -4
  26. package/dist/chunks/publish-panel-m0fcgdrt.js +21 -0
  27. package/dist/chunks/publish-panel-m0fcgdrt.js.map +9 -0
  28. package/dist/chunks/quick-search-rhs6cbsb.js +53 -0
  29. package/dist/chunks/quick-search-rhs6cbsb.js.map +9 -0
  30. package/dist/chunks/right-panel-zpdxd98t.js +37 -0
  31. package/dist/chunks/right-panel-zpdxd98t.js.map +9 -0
  32. package/dist/chunks/section-registry-zmckaxf4.js +30 -0
  33. package/dist/chunks/section-registry-zmckaxf4.js.map +9 -0
  34. package/dist/chunks/sp-overlay.dev-9ran0sfq.js +18 -0
  35. package/dist/chunks/sp-overlay.dev-9ran0sfq.js.map +9 -0
  36. package/dist/chunks/sp-popover.dev-rebmdncr.js +9 -0
  37. package/dist/chunks/sp-popover.dev-rebmdncr.js.map +9 -0
  38. package/dist/chunks/sp-progress-circle.dev-5vkppfkt.js +15 -0
  39. package/dist/chunks/sp-progress-circle.dev-5vkppfkt.js.map +9 -0
  40. package/dist/chunks/sp-tooltip.dev-ykqrg8h5.js +15 -0
  41. package/dist/chunks/sp-tooltip.dev-ykqrg8h5.js.map +9 -0
  42. package/dist/chunks/{sp-tray.dev-pb2fsd65.js → sp-tray.dev-ey59vfh7.js} +10 -8
  43. package/dist/chunks/{sp-tray.dev-pb2fsd65.js.map → sp-tray.dev-ey59vfh7.js.map} +2 -2
  44. package/dist/chunks/{studio-9qr6fq7p.js → studio-0rqqhrdy.js} +10 -378
  45. package/dist/chunks/{studio-9qr6fq7p.js.map → studio-0rqqhrdy.js.map} +4 -14
  46. package/dist/chunks/{studio-5x6tqqec.js → studio-2sj1jj3v.js} +10 -8
  47. package/dist/chunks/{studio-5x6tqqec.js.map → studio-2sj1jj3v.js.map} +2 -2
  48. package/dist/chunks/{studio-h3fw0wn8.js → studio-3fk1ac9z.js} +6 -4
  49. package/dist/chunks/{studio-h3fw0wn8.js.map → studio-3fk1ac9z.js.map} +2 -2
  50. package/dist/chunks/studio-41exgzhq.js +6690 -0
  51. package/dist/chunks/studio-41exgzhq.js.map +38 -0
  52. package/dist/chunks/{studio-7v2bpweq.js → studio-4mqb9a0j.js} +6 -4
  53. package/dist/chunks/{studio-7v2bpweq.js.map → studio-4mqb9a0j.js.map} +2 -2
  54. package/dist/chunks/studio-50k77cca.js +1787 -0
  55. package/dist/chunks/studio-50k77cca.js.map +18 -0
  56. package/dist/chunks/{studio-56v3zwk6.js → studio-6a0h7sne.js} +10 -8
  57. package/dist/chunks/{studio-56v3zwk6.js.map → studio-6a0h7sne.js.map} +2 -2
  58. package/dist/chunks/studio-6f0am8f2.js +53 -0
  59. package/dist/chunks/studio-6f0am8f2.js.map +10 -0
  60. package/dist/chunks/studio-6nmjc5qk.js +7002 -0
  61. package/dist/chunks/studio-6nmjc5qk.js.map +32 -0
  62. package/dist/chunks/studio-7afd21nw.js +67 -0
  63. package/dist/chunks/studio-7afd21nw.js.map +10 -0
  64. package/dist/chunks/{studio-xze4zrs1.js → studio-7bmdvzne.js} +2 -2
  65. package/dist/chunks/studio-7bmdvzne.js.map +10 -0
  66. package/dist/chunks/studio-8jpzqk5s.js +128 -0
  67. package/dist/chunks/studio-8jpzqk5s.js.map +10 -0
  68. package/dist/chunks/studio-94m77tgk.js +1482 -0
  69. package/dist/chunks/studio-94m77tgk.js.map +13 -0
  70. package/dist/chunks/studio-9je6w5sq.js +1000 -0
  71. package/dist/chunks/studio-9je6w5sq.js.map +16 -0
  72. package/dist/chunks/{studio-w7c6kvk7.js → studio-a7pzveq1.js} +2 -2
  73. package/dist/chunks/{studio-gs6kqdt1.js → studio-adn0z1k3.js} +14 -8
  74. package/dist/chunks/{studio-gs6kqdt1.js.map → studio-adn0z1k3.js.map} +3 -3
  75. package/dist/chunks/{studio-kr88ryr7.js → studio-bsxxx3x2.js} +17 -15
  76. package/dist/chunks/{studio-kr88ryr7.js.map → studio-bsxxx3x2.js.map} +7 -6
  77. package/dist/chunks/studio-bw5dz6km.js +608 -0
  78. package/dist/chunks/studio-bw5dz6km.js.map +21 -0
  79. package/dist/chunks/studio-c6s5getc.js +4232 -0
  80. package/dist/chunks/studio-c6s5getc.js.map +39 -0
  81. package/dist/chunks/studio-cjm7fdjf.js +451 -0
  82. package/dist/chunks/studio-cjm7fdjf.js.map +14 -0
  83. package/dist/chunks/{studio-cx91mnh8.js → studio-ckwz4d3x.js} +53 -53
  84. package/dist/chunks/{studio-cx91mnh8.js.map → studio-ckwz4d3x.js.map} +5 -5
  85. package/dist/chunks/studio-dg7zyt5x.js +28 -0
  86. package/dist/chunks/studio-dg7zyt5x.js.map +10 -0
  87. package/dist/chunks/studio-dzcyvvvz.js +166 -0
  88. package/dist/chunks/studio-dzcyvvvz.js.map +10 -0
  89. package/dist/chunks/{studio-7kqe41sa.js → studio-evkr5d3b.js} +15 -7
  90. package/dist/chunks/{studio-7kqe41sa.js.map → studio-evkr5d3b.js.map} +5 -4
  91. package/dist/chunks/studio-f12h6177.js +411 -0
  92. package/dist/chunks/studio-f12h6177.js.map +13 -0
  93. package/dist/chunks/studio-hbp1s8m9.js +1817 -0
  94. package/dist/chunks/studio-hbp1s8m9.js.map +14 -0
  95. package/dist/chunks/studio-k2sxjph5.js +15295 -0
  96. package/dist/chunks/studio-k2sxjph5.js.map +235 -0
  97. package/dist/chunks/studio-myje51t3.js +385 -0
  98. package/dist/chunks/studio-myje51t3.js.map +12 -0
  99. package/dist/chunks/studio-na6rwzhw.js +353 -0
  100. package/dist/chunks/studio-na6rwzhw.js.map +11 -0
  101. package/dist/chunks/{studio-4k0m4fmp.js → studio-p471gg5n.js} +126 -3
  102. package/dist/chunks/studio-p471gg5n.js.map +14 -0
  103. package/dist/chunks/{studio-9bjwf0d4.js → studio-rgwcpast.js} +5 -46
  104. package/dist/chunks/{studio-9bjwf0d4.js.map → studio-rgwcpast.js.map} +4 -5
  105. package/dist/chunks/{studio-esrewxap.js → studio-s519zc99.js} +3 -3
  106. package/dist/chunks/studio-sas7mt3k.js +75 -0
  107. package/dist/chunks/studio-sas7mt3k.js.map +10 -0
  108. package/dist/chunks/studio-t5wqwzdh.js +74 -0
  109. package/dist/chunks/studio-t5wqwzdh.js.map +10 -0
  110. package/dist/chunks/{studio-9qpspwfn.js → studio-temv5bw4.js} +5 -5
  111. package/dist/chunks/{studio-9qpspwfn.js.map → studio-temv5bw4.js.map} +1 -1
  112. package/dist/chunks/{studio-qkvt37c0.js → studio-tr20m8g8.js} +669 -18
  113. package/dist/chunks/studio-tr20m8g8.js.map +9 -0
  114. package/dist/chunks/{studio-v7jkjqng.js → studio-v96qfnww.js} +5514 -4291
  115. package/dist/chunks/studio-v96qfnww.js.map +54 -0
  116. package/dist/chunks/studio-vcetc91k.js +659 -0
  117. package/dist/chunks/studio-vcetc91k.js.map +10 -0
  118. package/dist/chunks/{studio-jm0z592w.js → studio-vmymjefp.js} +2972 -4937
  119. package/dist/chunks/studio-vmymjefp.js.map +98 -0
  120. package/dist/chunks/{studio-bghqfmea.js → studio-wwseqyw0.js} +2 -2
  121. package/dist/chunks/studio-x9hmy5kr.js +390 -0
  122. package/dist/chunks/studio-x9hmy5kr.js.map +10 -0
  123. package/dist/chunks/studio-xa0ce0n7.js +335 -0
  124. package/dist/chunks/studio-xa0ce0n7.js.map +10 -0
  125. package/dist/chunks/studio-xm3dbxvn.js +378 -0
  126. package/dist/chunks/studio-xm3dbxvn.js.map +19 -0
  127. package/dist/chunks/{studio-nqw433kv.js → studio-xmvbhg31.js} +2 -2
  128. package/dist/chunks/studio-zv37yrfa.js +1054 -0
  129. package/dist/chunks/studio-zv37yrfa.js.map +13 -0
  130. package/dist/chunks/{ws-client-q7hkz3j9.js → ws-client-654ts66j.js} +6 -4
  131. package/dist/chunks/ws-client-654ts66j.js.map +11 -0
  132. package/dist/iframe-entry.js +668 -230
  133. package/dist/studio.js +21080 -32585
  134. package/index.html +35 -4685
  135. package/package.json +17 -15
  136. package/src/about/about-modal.ts +42 -11
  137. package/src/browse/library-commands.ts +240 -0
  138. package/src/browse/library-layouts.ts +369 -0
  139. package/src/browse/library-model.ts +396 -0
  140. package/src/browse/library-pane.ts +1049 -0
  141. package/src/browse/library-preview.ts +252 -0
  142. package/src/browse/library-source.ts +150 -0
  143. package/src/canvas/canvas-helpers.ts +33 -9
  144. package/src/canvas/canvas-live-render.ts +45 -29
  145. package/src/canvas/canvas-patcher.ts +151 -31
  146. package/src/canvas/canvas-perf.ts +46 -7
  147. package/src/canvas/canvas-render.ts +1092 -220
  148. package/src/canvas/canvas-surface.ts +240 -0
  149. package/src/canvas/canvas-utils.ts +1166 -124
  150. package/src/canvas/iframe-drop.ts +2 -1
  151. package/src/canvas/iframe-entry.ts +241 -4
  152. package/src/canvas/iframe-host.ts +1226 -143
  153. package/src/canvas/iframe-inline-edit.ts +31 -0
  154. package/src/canvas/iframe-interaction.ts +76 -16
  155. package/src/canvas/iframe-keys.ts +107 -45
  156. package/src/canvas/iframe-overlay.ts +38 -1
  157. package/src/canvas/iframe-protocol.ts +137 -2
  158. package/src/canvas/iframe-render.ts +70 -2
  159. package/src/canvas/iframe-slash.ts +6 -0
  160. package/src/canvas/path-mapping.ts +56 -6
  161. package/src/canvas/surface-registry.ts +292 -0
  162. package/src/collab/collab-commands.ts +244 -0
  163. package/src/collab/collab-session.ts +198 -32
  164. package/src/collab/collab-state.ts +28 -2
  165. package/src/collab/presence-chips.ts +100 -9
  166. package/src/commands/active-registry.ts +42 -0
  167. package/src/commands/app-commands.ts +176 -0
  168. package/src/commands/budget.ts +158 -0
  169. package/src/commands/command-args.ts +253 -0
  170. package/src/commands/context.ts +387 -0
  171. package/src/commands/defaults.ts +580 -0
  172. package/src/commands/keymap.ts +571 -0
  173. package/src/commands/levels.ts +345 -0
  174. package/src/commands/live-context.ts +193 -0
  175. package/src/commands/reference.ts +227 -0
  176. package/src/commands/registry.ts +293 -0
  177. package/src/component-props.ts +3 -2
  178. package/src/content/draft-state.ts +106 -0
  179. package/src/content/entry-commands.ts +282 -0
  180. package/src/content/entry-editor.ts +327 -0
  181. package/src/content/entry-fields.ts +116 -0
  182. package/src/content/entry-model.ts +175 -0
  183. package/src/editor/canvas-context-menu.ts +17 -11
  184. package/src/editor/canvas-slash-bridge.ts +11 -4
  185. package/src/editor/context-menu.ts +804 -204
  186. package/src/editor/convert-to-component.ts +31 -10
  187. package/src/editor/convert-to-repeater.ts +3 -2
  188. package/src/editor/inline-edit-apply.ts +5 -5
  189. package/src/editor/inline-edit.ts +86 -50
  190. package/src/editor/insert-zone-action.ts +1 -1
  191. package/src/editor/shortcuts.ts +1043 -329
  192. package/src/editor/slash-menu.ts +1 -0
  193. package/src/files/elements.ts +166 -0
  194. package/src/files/file-ops.ts +178 -31
  195. package/src/files/files.ts +898 -268
  196. package/src/files/fs-events.ts +21 -0
  197. package/src/files/media-meta.ts +203 -0
  198. package/src/files/media-paths.ts +141 -0
  199. package/src/files/media-upload.ts +7 -3
  200. package/src/files/media-usage.ts +189 -0
  201. package/src/github/github-auth.ts +235 -33
  202. package/src/github/github-publish.ts +80 -21
  203. package/src/grid/grid-controller.ts +152 -12
  204. package/src/grid/grid-layout.ts +225 -20
  205. package/src/grid/grid-open.ts +120 -0
  206. package/src/grid/grid-panel.ts +554 -39
  207. package/src/grid/grid-source.ts +27 -1
  208. package/src/grid/redirects-grid.ts +599 -0
  209. package/src/grid/redirects.ts +398 -0
  210. package/src/grid/sources/content-source.ts +108 -2
  211. package/src/i18n/i18n-commands.ts +322 -0
  212. package/src/new-project/add-repo-modal.ts +8 -10
  213. package/src/new-project/import-tab.ts +7 -0
  214. package/src/new-project/location-fields.ts +9 -1
  215. package/src/new-project/new-project-modal.ts +221 -245
  216. package/src/packages/jxsuite-update.ts +97 -48
  217. package/src/packages/pull-package-sync.ts +18 -9
  218. package/src/page-params.ts +2 -1
  219. package/src/panels/activity-bar.ts +152 -91
  220. package/src/panels/activity-panel.ts +481 -0
  221. package/src/panels/ai-chat/chat-markdown.ts +8 -3
  222. package/src/panels/ai-chat/chat-view.ts +245 -18
  223. package/src/panels/ai-chat/composer.ts +31 -5
  224. package/src/panels/ai-chat/sessions-view.ts +17 -10
  225. package/src/panels/ai-panel.ts +279 -53
  226. package/src/panels/block-action-bar.ts +1115 -224
  227. package/src/panels/bottom-dock.ts +337 -0
  228. package/src/panels/canvas-dnd-bridge.ts +37 -8
  229. package/src/panels/chat-panel.ts +29 -12
  230. package/src/panels/data-explorer.ts +254 -112
  231. package/src/panels/data-grid.ts +10 -4
  232. package/src/panels/dnd.ts +28 -51
  233. package/src/panels/editors.ts +434 -107
  234. package/src/panels/elements-panel.ts +66 -2
  235. package/src/panels/empty-state.ts +112 -0
  236. package/src/panels/events-panel.ts +596 -82
  237. package/src/panels/formula-workspace.ts +408 -218
  238. package/src/panels/frontmatter-fields.ts +51 -10
  239. package/src/panels/frontmatter-panel.ts +357 -79
  240. package/src/panels/git-panel.ts +375 -164
  241. package/src/panels/head-panel.ts +791 -85
  242. package/src/panels/i18n-panel.ts +592 -0
  243. package/src/panels/imports-panel.ts +95 -85
  244. package/src/panels/jump-bar.ts +584 -0
  245. package/src/panels/layers-panel.ts +1106 -328
  246. package/src/panels/left-panel.ts +109 -204
  247. package/src/panels/navigator-panels.ts +131 -0
  248. package/src/panels/overlays.ts +7 -2
  249. package/src/panels/pane-context.ts +1477 -0
  250. package/src/panels/pane-grid.ts +406 -0
  251. package/src/panels/panel-registry.ts +340 -0
  252. package/src/panels/panel-scheduler.ts +75 -0
  253. package/src/panels/problems-panel.ts +234 -0
  254. package/src/panels/properties-panel.ts +946 -1139
  255. package/src/panels/provenance.ts +258 -0
  256. package/src/panels/quick-search.ts +791 -122
  257. package/src/panels/right-panel.ts +228 -102
  258. package/src/panels/seo-modal.ts +395 -0
  259. package/src/panels/settings-pane.ts +203 -0
  260. package/src/panels/signals-panel.ts +354 -137
  261. package/src/panels/statement-editor.ts +41 -25
  262. package/src/panels/statusbar.ts +342 -106
  263. package/src/panels/style-panel.ts +974 -463
  264. package/src/panels/style-utils.ts +3 -3
  265. package/src/panels/stylebook-doc.ts +4 -2
  266. package/src/panels/stylebook-layers-panel.ts +3 -2
  267. package/src/panels/stylebook-panel.ts +51 -26
  268. package/src/panels/tab-strip.ts +1058 -78
  269. package/src/panels/target-line.ts +274 -0
  270. package/src/panels/toolbar.ts +631 -484
  271. package/src/panels/welcome-screen.ts +301 -198
  272. package/src/platform-errors.ts +26 -3
  273. package/src/platform.ts +96 -2
  274. package/src/platforms/cloud.ts +71 -8
  275. package/src/platforms/devserver.ts +92 -29
  276. package/src/publish/deploy-checklist.ts +383 -0
  277. package/src/publish/pages-service.ts +10 -1
  278. package/src/publish/publish-commands.ts +218 -0
  279. package/src/publish/publish-panel.ts +110 -16
  280. package/src/recent-projects.ts +3 -2
  281. package/src/services/a11y-report.ts +505 -0
  282. package/src/services/ai-models.ts +30 -1
  283. package/src/services/ai-project-tools.ts +29 -10
  284. package/src/services/ai-system-prompt.ts +53 -14
  285. package/src/services/ai-tools.ts +39 -3
  286. package/src/services/ai-writes.ts +154 -0
  287. package/src/services/announce.ts +97 -0
  288. package/src/services/automation.ts +424 -175
  289. package/src/services/cem-export.ts +9 -4
  290. package/src/services/clock.ts +61 -0
  291. package/src/services/document-assistant.ts +36 -8
  292. package/src/services/idle.ts +179 -0
  293. package/src/services/import-client.ts +26 -0
  294. package/src/services/monaco-buffer.ts +627 -0
  295. package/src/services/monaco-lazy.ts +36 -0
  296. package/src/services/notify.ts +271 -0
  297. package/src/services/profile.ts +155 -0
  298. package/src/services/references.ts +0 -0
  299. package/src/services/token-lint.ts +3 -2
  300. package/src/services/tool-executor.ts +53 -7
  301. package/src/services/trusted-types.ts +123 -0
  302. package/src/settings/contexts-section.ts +427 -0
  303. package/src/settings/contributed-section.ts +149 -11
  304. package/src/settings/css-vars-editor.ts +306 -349
  305. package/src/settings/defs-editor.ts +93 -34
  306. package/src/settings/dependencies-editor.ts +7 -7
  307. package/src/settings/extension-sections.ts +37 -5
  308. package/src/settings/general-settings.ts +204 -121
  309. package/src/settings/locales-section.ts +347 -0
  310. package/src/settings/preferences-accounts.ts +140 -0
  311. package/src/settings/preferences-dialog.ts +620 -0
  312. package/src/settings/preferences-keymap.ts +270 -0
  313. package/src/settings/project-sections.ts +260 -0
  314. package/src/settings/section-registry.ts +169 -0
  315. package/src/settings/settings-document.ts +385 -0
  316. package/src/shell.ts +1549 -0
  317. package/src/site-context.ts +45 -20
  318. package/src/state.ts +14 -275
  319. package/src/store.ts +88 -43
  320. package/src/studio.ts +790 -346
  321. package/src/style/live-preview.ts +36 -0
  322. package/src/style/project-styles.ts +349 -0
  323. package/src/style/token-ref.ts +123 -0
  324. package/src/tabs/patch-ops.ts +16 -4
  325. package/src/tabs/project-config.ts +416 -0
  326. package/src/tabs/selection.ts +323 -0
  327. package/src/tabs/tab.ts +182 -47
  328. package/src/tabs/transact.ts +209 -28
  329. package/src/types.ts +68 -13
  330. package/src/ui/ai-credentials-form.ts +39 -35
  331. package/src/ui/ai-managed-connect.ts +7 -1
  332. package/src/ui/dynamic-slot.ts +224 -81
  333. package/src/ui/expression-editor.ts +71 -63
  334. package/src/ui/field-input.ts +4 -6
  335. package/src/ui/field-row.ts +215 -38
  336. package/src/ui/form-controls.ts +194 -7
  337. package/src/ui/layers.ts +492 -69
  338. package/src/ui/media-picker.ts +115 -25
  339. package/src/ui/panel-resize.ts +109 -91
  340. package/src/ui/progress-modal.ts +131 -50
  341. package/src/ui/regions.ts +462 -0
  342. package/src/ui/schema-form.ts +229 -45
  343. package/src/ui/spectrum.ts +24 -8
  344. package/src/ui/timing.ts +49 -0
  345. package/src/ui/value-selector.ts +20 -0
  346. package/src/ui/value-source.ts +348 -0
  347. package/src/ui/virtual-window.ts +400 -0
  348. package/src/utils/edit-display.ts +3 -2
  349. package/src/utils/inherited-style.ts +67 -37
  350. package/src/view.ts +64 -78
  351. package/src/workspace/pane-derive.ts +1258 -0
  352. package/src/workspace/session.ts +317 -0
  353. package/src/workspace/workspace.ts +1345 -23
  354. package/styles/canvas.css +653 -0
  355. package/styles/forced-colors.css +106 -0
  356. package/styles/inspector.css +1454 -0
  357. package/styles/overlays.css +2704 -0
  358. package/styles/panels.css +1701 -0
  359. package/styles/shell.css +1423 -0
  360. package/styles/tokens.css +151 -0
  361. package/dist/chunks/Overlay.dev-cszx7yn5.js +0 -22
  362. package/dist/chunks/extension-sections-qr3drkkw.js +0 -531
  363. package/dist/chunks/extension-sections-qr3drkkw.js.map +0 -12
  364. package/dist/chunks/files-j8a1t06k.js +0 -50
  365. package/dist/chunks/files-j8a1t06k.js.map +0 -9
  366. package/dist/chunks/git-panel-xtq5z81x.js +0 -28
  367. package/dist/chunks/git-panel-xtq5z81x.js.map +0 -9
  368. package/dist/chunks/layers-panel-ancn2wpq.js +0 -26
  369. package/dist/chunks/layers-panel-ancn2wpq.js.map +0 -9
  370. package/dist/chunks/settings-modal-17ttr96g.js +0 -29
  371. package/dist/chunks/settings-modal-17ttr96g.js.map +0 -9
  372. package/dist/chunks/sp-overlay.dev-earn5as8.js +0 -17
  373. package/dist/chunks/sp-overlay.dev-earn5as8.js.map +0 -9
  374. package/dist/chunks/sp-popover.dev-frxtfejn.js +0 -8
  375. package/dist/chunks/sp-popover.dev-frxtfejn.js.map +0 -9
  376. package/dist/chunks/sp-progress-circle.dev-7tvw34h8.js +0 -14
  377. package/dist/chunks/sp-progress-circle.dev-7tvw34h8.js.map +0 -9
  378. package/dist/chunks/sp-tooltip.dev-a9fywesm.js +0 -21
  379. package/dist/chunks/sp-tooltip.dev-a9fywesm.js.map +0 -10
  380. package/dist/chunks/studio-0xgs7xrs.js +0 -429
  381. package/dist/chunks/studio-0xgs7xrs.js.map +0 -13
  382. package/dist/chunks/studio-4d8hzcz5.js +0 -3427
  383. package/dist/chunks/studio-4d8hzcz5.js.map +0 -26
  384. package/dist/chunks/studio-4k0m4fmp.js.map +0 -13
  385. package/dist/chunks/studio-9smfzqhc.js +0 -19
  386. package/dist/chunks/studio-9smfzqhc.js.map +0 -10
  387. package/dist/chunks/studio-jm0z592w.js.map +0 -115
  388. package/dist/chunks/studio-m2t4mdg5.js +0 -1854
  389. package/dist/chunks/studio-m2t4mdg5.js.map +0 -18
  390. package/dist/chunks/studio-n4p11dfy.js +0 -853
  391. package/dist/chunks/studio-n4p11dfy.js.map +0 -12
  392. package/dist/chunks/studio-qkvt37c0.js.map +0 -9
  393. package/dist/chunks/studio-sjh1wt1a.js +0 -3744
  394. package/dist/chunks/studio-sjh1wt1a.js.map +0 -22
  395. package/dist/chunks/studio-v7jkjqng.js.map +0 -46
  396. package/dist/chunks/studio-xq0t7n5t.js +0 -140
  397. package/dist/chunks/studio-xq0t7n5t.js.map +0 -10
  398. package/dist/chunks/studio-xze4zrs1.js.map +0 -10
  399. package/dist/chunks/studio-yfcstdzy.js +0 -399
  400. package/dist/chunks/studio-yfcstdzy.js.map +0 -12
  401. package/dist/chunks/studio-zp7jncs8.js +0 -469
  402. package/dist/chunks/studio-zp7jncs8.js.map +0 -14
  403. package/dist/chunks/ws-client-q7hkz3j9.js.map +0 -11
  404. package/src/browse/browse-modal.ts +0 -69
  405. package/src/browse/browse.ts +0 -904
  406. package/src/canvas/canvas-diff.ts +0 -216
  407. package/src/canvas/nested-site-style.ts +0 -58
  408. package/src/new-project/design-fields.ts +0 -264
  409. package/src/new-project/templates.ts +0 -61
  410. package/src/panels/tab-bar.ts +0 -548
  411. package/src/settings/settings-modal.ts +0 -203
  412. /package/dist/chunks/{adapter-z8fkmbmv.js.map → adapter-qfnesn5v.js.map} +0 -0
  413. /package/dist/chunks/{cf-settings-mmyv436d.js.map → cf-settings-73sh5z4p.js.map} +0 -0
  414. /package/dist/chunks/{format-host-8jxwts2p.js.map → format-host-mkwsmt8d.js.map} +0 -0
  415. /package/dist/chunks/{jx-validate-qx8degj7.js.map → jx-validate-v6qgknrs.js.map} +0 -0
  416. /package/dist/chunks/{studio-w7c6kvk7.js.map → studio-a7pzveq1.js.map} +0 -0
  417. /package/dist/chunks/{studio-esrewxap.js.map → studio-s519zc99.js.map} +0 -0
  418. /package/dist/chunks/{studio-bghqfmea.js.map → studio-wwseqyw0.js.map} +0 -0
  419. /package/dist/chunks/{studio-nqw433kv.js.map → studio-xmvbhg31.js.map} +0 -0
@@ -7,22 +7,35 @@
7
7
  * file-ops.js. Every name the user supplies (new file, rename) is collected with the Spectrum
8
8
  * prompt dialog from ui/layers.ts — never a native browser prompt (studio-ui-guidelines.md §8.7).
9
9
  *
10
+ * **The tree is a flat list of rows, and the DOM holds a window onto it** ({@link FileRow},
11
+ * `ui/virtual-window.ts`). It used to recurse a template per directory level, which is why it drew
12
+ * every expanded row of every expanded directory — a `node_modules` expanded by accident is tens of
13
+ * thousands of `sp-icon` custom elements, built synchronously, on every repaint. A recursion has no
14
+ * row list to window, so the recursion moved into {@link collectFileRows}, which produces the rows
15
+ * in display order and nothing else; the render is then a window over that array.
16
+ *
17
+ * Flattening costs the `role="group"` wrappers, and pays for them with `aria-level` +
18
+ * `aria-posinset` / `aria-setsize` on every row — the same shape the Outline has always had, and
19
+ * the only shape that stays TRUE when the tree draws eleven rows out of ten thousand.
20
+ *
10
21
  * @docs studio/interface
11
22
  */
12
23
 
13
24
  import { html, nothing } from "lit-html";
14
25
  import { errorMessage } from "@jxsuite/schema/parse";
26
+ import { localeLabel, localeOfPath, resolveI18n } from "@jxsuite/schema/locale";
15
27
  import { classMap } from "lit-html/directives/class-map.js";
16
28
  import { ref } from "lit-html/directives/ref.js";
17
- import { renderPopover, showConfirmDialog, showPromptDialog } from "../ui/layers";
18
- import { createState, projectState, requireProjectState, setProjectState } from "../store";
29
+ import { renderPopover, showPromptDialog } from "../ui/layers";
30
+ import { projectState, requireProjectState, setProjectState } from "../store";
19
31
  import { getPlatform } from "../platform";
20
- import { statusMessage } from "../panels/statusbar";
32
+ import { notify } from "../services/notify";
21
33
  import { loadComponentRegistry } from "./components";
22
34
  import { ensureDependenciesInstalled } from "../packages/ensure-deps";
23
35
  import { maybePromptJxsuiteUpdate } from "../packages/jxsuite-update";
24
36
  import { autoSyncProjectOnOpen } from "../packages/pull-package-sync";
25
37
  import { markLocalMutation } from "./fs-events";
38
+ import { registerPanel } from "../panels/panel-registry";
26
39
  import { UPLOAD_ACCEPT, isImage, uploadAssets } from "./media-upload";
27
40
  import { isCollabPath } from "../collab/collab-state";
28
41
  import {
@@ -33,15 +46,21 @@ import {
33
46
  import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
34
47
  import {
35
48
  activateTab,
49
+ moveTabToPane,
36
50
  openTab,
51
+ paneOfTab,
52
+ receivingPane,
37
53
  renameTab,
38
54
  replaceAllTabs,
39
55
  setWorkspaceProject,
40
56
  workspace,
41
57
  } from "../workspace/workspace";
42
- import { openCollectionGrid, openCsvGridTab, openPagesGrid } from "../grid/grid-open";
58
+ import { openCsvGridTab, openPagesGrid } from "../grid/grid-open";
43
59
  import { collectionDirs } from "../grid/sources/content-source";
44
- import { parseSourceForPath, serializeDocument } from "./file-ops";
60
+ import { activeRegistry } from "../commands/active-registry";
61
+ import { collectionOfPath } from "../content/entry-model";
62
+ import { confirmFileDelete, parseSourceForPath, renamePromptMessage } from "./file-ops";
63
+ import { invalidateUsages } from "../services/references";
45
64
  import {
46
65
  documentExtensions,
47
66
  formatForPath,
@@ -50,14 +69,23 @@ import {
50
69
  refreshExtensionUi,
51
70
  refreshFormats,
52
71
  } from "../format/format-host";
53
- import { view } from "../view";
72
+ import { markSessionRestored, persistedSession, resetProjectShell, setActivityTab } from "../shell";
73
+ import { restoreSession } from "../workspace/session";
74
+ import { cleanupGitPanel } from "../panels/git-panel";
54
75
  import { addRecentProject, trackRecentFile } from "../recent-projects";
76
+ import {
77
+ listWindow,
78
+ measuredRowHeight,
79
+ revealListRow,
80
+ watchListWindow,
81
+ } from "../ui/virtual-window";
55
82
  import type { TemplateResult } from "lit-html";
56
83
  import type { JxMutableNode } from "@jxsuite/schema/types";
57
- import type { StudioState } from "../state.js";
58
- import type { Tab } from "../tabs/tab.js";
84
+ import type { ResolvedI18n } from "@jxsuite/schema/locale";
59
85
  import type { DirEntry, RenameResult } from "../types";
86
+ import type { ListWindowWatch } from "../ui/virtual-window";
60
87
  import { rectOf } from "../utils/geometry";
88
+ import { repeat } from "lit-html/directives/repeat.js";
61
89
 
62
90
  // ─── File icon map ────────────────────────────────────────────────────────────
63
91
 
@@ -123,7 +151,7 @@ export async function loadProject() {
123
151
  await ensureDependenciesInstalled();
124
152
  await loadDirectory(".");
125
153
  await loadComponentRegistry();
126
- await openHomePage();
154
+ await openLastSessionOrHome();
127
155
  void maybePromptJxsuiteUpdate(meta.root);
128
156
  }
129
157
  // If not a site project (monorepo) — show welcome prompt, don't load tree
@@ -135,25 +163,25 @@ export async function loadProject() {
135
163
  // ─── Open Project (PAL-based) ─────────────────────────────────────────────
136
164
 
137
165
  /**
138
- * Open a project via the platform adapter.
166
+ * Open a project via the platform adapter, into THIS window.
139
167
  *
140
- * @param {{
141
- * renderActivityBar: () => void;
142
- * renderLeftPanel: () => void;
143
- * }} ctx
168
+ * Reports whether a project was actually opened: a cancelled picker and a failed open both leave
169
+ * the window on the project it already had, and the caller announces the outcome — "Opening the
170
+ * project…" over a dialog the user just dismissed is a report of something that did not happen.
171
+ *
172
+ * @param {{ renderLeftPanel: () => void }} ctx
173
+ * @returns {Promise<boolean>} Whether the window changed project.
144
174
  */
145
175
  export async function openProject({
146
- renderActivityBar,
147
176
  renderLeftPanel,
148
177
  }: {
149
- renderActivityBar: () => void;
150
178
  renderLeftPanel: () => void;
151
- }) {
179
+ }): Promise<boolean> {
152
180
  try {
153
181
  const platform = getPlatform();
154
182
  const result = await platform.openProject();
155
183
  if (!result) {
156
- return;
184
+ return false;
157
185
  } // User cancelled
158
186
 
159
187
  const { config, handle } = result;
@@ -206,16 +234,63 @@ export async function openProject({
206
234
  }
207
235
  requireProjectState().projectDirs = foundDirs;
208
236
 
209
- view.leftTab = "files";
237
+ // Source control, the stylebook selection and the settings tab describe the project being
238
+ // Left behind. The poll timer goes with them; the panel re-arms it on its next render.
239
+ cleanupGitPanel();
240
+ resetProjectShell();
241
+ setActivityTab("files");
210
242
  addRecentProject(requireProjectState().name, requireProjectState().projectRoot);
211
- renderActivityBar();
212
243
  renderLeftPanel();
213
- statusMessage(`Opened project: ${requireProjectState().name}`);
244
+ // The project's name is permanent state in the status bar's PROJECT field now.
214
245
 
215
- await openHomePage();
246
+ await openLastSessionOrHome();
216
247
  void maybePromptJxsuiteUpdate(requireProjectState().projectRoot);
248
+ return true;
249
+ } catch (error) {
250
+ notify.error("Could not open the project.", {
251
+ detail: errorMessage(error),
252
+ source: "Open Project",
253
+ });
254
+ return false;
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Give a freshly created project a git repository, so its first irreversible action is recoverable.
260
+ *
261
+ * Runs on the create/import path only, immediately after the backend has written the project — the
262
+ * scaffold is not a repository and nothing else in Studio says so, while Delete and Rename are one
263
+ * confirm click away. `activate` binds the backend to the new root first, because `gitInit` takes
264
+ * no argument and would otherwise run against whichever project the window was serving.
265
+ *
266
+ * Never re-initialises, and never runs on repository-backed platforms (`createDestination: "repo"`
267
+ * — a cloud project _is_ a GitHub repository, and its `gitInit` is a no-op by design).
268
+ *
269
+ * @param {string} root Absolute root of the project just created.
270
+ * @returns {Promise<boolean>} Whether a repository was initialised.
271
+ */
272
+ export async function initProjectRepo(root: string): Promise<boolean> {
273
+ const platform = getPlatform();
274
+ if (platform.createDestination !== "path") {
275
+ return false;
276
+ }
277
+ try {
278
+ await platform.activate(root);
279
+ const status = await platform.gitStatus();
280
+ if (status.isRepo) {
281
+ return false;
282
+ }
283
+ await platform.gitInit();
284
+ notify.success("Initialized a git repository for this project.");
285
+ return true;
217
286
  } catch (error) {
218
- statusMessage(`Error: ${errorMessage(error)}`);
287
+ // Version control is a safety net, not a precondition — a project that was written stays
288
+ // Written. Say so rather than failing the create the user just completed.
289
+ notify.warn("Could not initialize a git repository — the project itself was written.", {
290
+ detail: errorMessage(error),
291
+ source: "Source Control",
292
+ });
293
+ return false;
219
294
  }
220
295
  }
221
296
 
@@ -249,6 +324,51 @@ export async function openHomePage() {
249
324
  }
250
325
  }
251
326
 
327
+ /**
328
+ * Reopen the documents this project was last left with, or its home page if there are none.
329
+ *
330
+ * Plan §4.4, and P3's "Newly possible": **the session survives a relaunch.** Every one of the three
331
+ * ways into a project — the `?project=` bootstrap, the PAL picker and a recent-project open — ended
332
+ * at `openHomePage()`, so nine open documents, a split and the mode you were in were lost each
333
+ * time. The per-project record's own interface said "session state grows into this shape".
334
+ *
335
+ * ONE function for all three, because the three used to be three calls to `openHomePage` and this
336
+ * is exactly the kind of behaviour that lands on two of them.
337
+ *
338
+ * A path that no longer resolves is skipped, and a session that restores NOTHING falls through to
339
+ * the home page rather than leaving an empty window: files move, and a stale record must not cost
340
+ * you the one page a project can always show.
341
+ *
342
+ * @returns Whether a SESSION was restored — `false` means the home page was opened instead.
343
+ */
344
+ export async function openLastSessionOrHome(): Promise<boolean> {
345
+ // `workspace.projectRoot`, and NOT a root passed in: that is the key `persistProjectShell` writes
346
+ // Under, and the three callers each know the project by a slightly different name — `meta.root`,
347
+ // `projectState.projectRoot`, the recent-list entry. One reader of one field cannot disagree with
348
+ // The writer; three callers passing three spellings silently restore nothing.
349
+ const session = persistedSession(workspace.projectRoot);
350
+ // Read first, THEN allow writes: the persist effect fires the moment `workspace.projectRoot` is
351
+ // Set, and an empty workspace captured at that instant would overwrite the very record this line
352
+ // Reads. See `markSessionRestored`.
353
+ markSessionRestored(workspace.projectRoot);
354
+ if (session) {
355
+ const opened = await restoreSession(session, {
356
+ ensureSecondPane: () => {
357
+ receivingPane();
358
+ },
359
+ openFile: (path, paneId) => openFileInTab(path, { focus: false, paneId }),
360
+ });
361
+ if (opened > 0) {
362
+ return true;
363
+ }
364
+ }
365
+ await openHomePage();
366
+ // Whether a SESSION was restored, which is not the same as whether anything opened: the
367
+ // `?project=` boot needs to know if it should still run its own inline open, and a home page is
368
+ // Not an answer to that question.
369
+ return false;
370
+ }
371
+
252
372
  // ─── File tree templates ──────────────────────────────────────────────────────
253
373
 
254
374
  function fileTypeIconTpl(name: string, type: string) {
@@ -364,27 +484,113 @@ export function renderFilesTemplate({
364
484
  @submit=${(e: Event) => e.preventDefault()}
365
485
  ></sp-search>
366
486
  </div>
367
- <div class="file-tree" role="tree" aria-label="Project files">
368
- ${renderTreeLevelTemplate(".", 0, { openFileFn, renderLeftPanel })}
487
+ <div
488
+ class="file-tree"
489
+ role="tree"
490
+ aria-label="Project files"
491
+ ${ref((el) => {
492
+ if (el) {
493
+ afterFileTreeRender(el as HTMLElement);
494
+ }
495
+ })}
496
+ >
497
+ ${fileTreeBodyTemplate({ openFileFn, renderLeftPanel })}
369
498
  </div>
370
499
  `;
371
500
  }
372
501
 
373
- /** @returns {import("lit-html").TemplateResult | import("lit-html").TemplateResult[]} */
374
- function renderTreeLevelTemplate(
502
+ // ─── The row model, and the window onto it ───────────────────────────────────
503
+
504
+ /**
505
+ * One row the Files tree would draw, in display order.
506
+ *
507
+ * An expanded directory contributes its children immediately after its own row, so an index into
508
+ * this array is what "the row below this one" MEANS for the keyboard — and it goes on meaning it
509
+ * whether or not the row below happens to be painted.
510
+ */
511
+ interface FileRow {
512
+ /**
513
+ * Lit's `repeat` key.
514
+ *
515
+ * Keyed, where the recursive form was positional: a windowed list re-uses its DOM nodes for
516
+ * DIFFERENT rows as the window slides, so positional reuse would leave the keyboard focused on an
517
+ * element that has since become another file. The prefix is what keeps a directory's own row and
518
+ * the "Loading…" row underneath it apart — they name the same path.
519
+ */
520
+ key: string;
521
+ path: string;
522
+ name: string;
523
+ type: string;
524
+ depth: number;
525
+ expanded: boolean;
526
+ /** A placeholder for a directory whose listing has not arrived yet. Not a `treeitem`. */
527
+ loading: boolean;
528
+ /** 1-based position among this row's siblings (`aria-posinset`). */
529
+ posInSet: number;
530
+ /** How many siblings the row has, itself included (`aria-setsize`). */
531
+ setSize: number;
532
+ /**
533
+ * The declared locale whose directory this row sits under, or absent.
534
+ *
535
+ * A chip, not a filter term: the tree's search still matches `entry.name` alone, because a query
536
+ * that silently also matched a language would make "why is this file here" unanswerable from what
537
+ * is on screen.
538
+ */
539
+ locale?: string | undefined;
540
+ }
541
+
542
+ /**
543
+ * The declared height of one row — `styles/panels.css` `.file-tree-item { block-size: 24px }`.
544
+ *
545
+ * The first paint of a session windows by this constant, because nothing has been laid out yet to
546
+ * measure; {@link fileRowHeight} measures a real row from then on and believes the measurement.
547
+ */
548
+ export const FILE_ROW_HEIGHT = 24;
549
+
550
+ /** The rows the tree last built, in display order. */
551
+ let _fileRows: FileRow[] = [];
552
+ /** The `.file-tree` element, kept between renders so the next one can be windowed. */
553
+ let _fileList: HTMLElement | null = null;
554
+ /** The scroll watch that repaints the tree as its scroller moves. */
555
+ let _fileWatch: ListWindowWatch | null = null;
556
+ /** The Navigator repaint, captured per render so the scroll watch never holds a stale one. */
557
+ let _filesRerender: (() => void) | null = null;
558
+ /** A keyboard jump that had to scroll first, spent by the repaint it provoked. */
559
+ let _pendingFocusPath: string | null = null;
560
+
561
+ /** The height one row actually has; the declared constant until a row has been laid out. */
562
+ function fileRowHeight(): number {
563
+ return measuredRowHeight(_fileList, ".file-tree-item", FILE_ROW_HEIGHT);
564
+ }
565
+
566
+ /**
567
+ * Append one directory's rows, and every expanded child directory's rows after their own row.
568
+ *
569
+ * The listing side effect stays exactly where the recursive template had it: a directory nobody has
570
+ * listed yet is fetched, and a placeholder holds its place until the repaint arrives.
571
+ */
572
+ function collectFileRows(
375
573
  dirPath: string,
376
574
  depth: number,
377
- ctx: { openFileFn: (path: string) => void; renderLeftPanel: () => void },
378
- ): TemplateResult | TemplateResult[] {
575
+ rows: FileRow[],
576
+ ctx: { renderLeftPanel: () => void },
577
+ i18n: ResolvedI18n | null,
578
+ ): void {
379
579
  const entries = requireProjectState().dirs.get(dirPath);
380
580
  if (!entries) {
381
581
  void loadDirectory(dirPath).then(() => ctx.renderLeftPanel());
382
- return html`<div
383
- class="file-tree-item"
384
- style="padding-left:${8 + depth * 16}px;color:var(--fg-dim);font-style:italic"
385
- >
386
- Loading…
387
- </div>`;
582
+ rows.push({
583
+ depth,
584
+ expanded: false,
585
+ key: `loading:${dirPath}`,
586
+ loading: true,
587
+ name: "Loading…",
588
+ path: dirPath,
589
+ posInSet: 1,
590
+ setSize: 1,
591
+ type: "file",
592
+ });
593
+ return;
388
594
  }
389
595
 
390
596
  const sorted = [...entries].toSorted((a, b) => {
@@ -402,126 +608,320 @@ function renderTreeLevelTemplate(
402
608
  ? sorted.filter((e) => e.type === "directory" || e.name.toLowerCase().includes(query))
403
609
  : sorted;
404
610
 
405
- return filtered.map((entry) => {
611
+ for (const [index, entry] of filtered.entries()) {
406
612
  const isDir = entry.type === "directory";
407
- const isExpanded = requireProjectState().expanded.has(entry.path);
408
- const isSelected = requireProjectState().selectedPath === entry.path;
409
-
410
- return html`
411
- <div
412
- class=${classMap({ "file-tree-item": true, selected: isSelected })}
413
- style="padding-left:${8 + depth * 16}px"
414
- role="treeitem"
415
- aria-level=${depth + 1}
416
- tabindex="-1"
417
- data-path=${entry.path}
418
- data-type=${entry.type}
419
- aria-expanded=${isDir ? String(isExpanded) : nothing}
420
- @click=${async (e: MouseEvent) => {
421
- e.stopPropagation();
422
- if (isDir) {
423
- if (isExpanded) {
424
- requireProjectState().expanded.delete(entry.path);
425
- } else {
426
- requireProjectState().expanded.add(entry.path);
427
- if (!requireProjectState().dirs.has(entry.path)) {
428
- await loadDirectory(entry.path);
429
- }
430
- }
431
- ctx.renderLeftPanel();
432
- } else {
433
- ctx.openFileFn(entry.path);
434
- }
435
- }}
436
- @contextmenu=${(e: MouseEvent) => {
437
- e.preventDefault();
438
- e.stopPropagation();
439
- showFileContextMenu(e, entry, ctx);
440
- }}
441
- >
442
- ${
443
- isDir
444
- ? html`<span class="file-tree-toggle">${isExpanded ? "\u25BC" : "\u25B6"}</span>`
445
- : html`<span class="file-tree-toggle empty"> </span>`
446
- }
447
- <span class="file-tree-icon">${fileTypeIconTpl(entry.path, entry.type)}</span>
448
- <span class="file-tree-name">${entry.name}</span>
449
- </div>
450
- ${
451
- isDir && isExpanded
452
- ? html`<div role="group">${renderTreeLevelTemplate(entry.path, depth + 1, ctx)}</div>`
453
- : nothing
454
- }
455
- `;
456
- });
613
+ const isExpanded = isDir && requireProjectState().expanded.has(entry.path);
614
+ rows.push({
615
+ depth,
616
+ expanded: isExpanded,
617
+ key: entry.path,
618
+ loading: false,
619
+ name: entry.name,
620
+ path: entry.path,
621
+ locale: localeOfPath(entry.path, i18n) ?? undefined,
622
+ posInSet: index + 1,
623
+ setSize: filtered.length,
624
+ type: entry.type,
625
+ });
626
+ if (isExpanded) {
627
+ collectFileRows(entry.path, depth + 1, rows, ctx, i18n);
628
+ }
629
+ }
457
630
  }
458
631
 
459
- export function setupTreeKeyboard(tree: HTMLElement) {
460
- tree.addEventListener("keydown", (e: KeyboardEvent) => {
461
- const items = [...tree.querySelectorAll(".file-tree-item")] as HTMLElement[];
462
- const focused = tree.querySelector(".file-tree-item:focus") as HTMLElement | null;
463
- if (!focused || items.length === 0) {
464
- return;
632
+ /** The model index of `path`, or -1. A "Loading…" row is keyed apart and never matches. */
633
+ function fileIndexOfPath(path: string | undefined): number {
634
+ return path === undefined ? -1 : _fileRows.findIndex((row) => !row.loading && row.path === path);
635
+ }
636
+
637
+ /** The next focusable row from `index`, walking by `step`; -1 at the ends. */
638
+ function fileStep(index: number, step: 1 | -1): number {
639
+ for (let i = index + step; i >= 0 && i < _fileRows.length; i += step) {
640
+ if (!_fileRows[i]!.loading) {
641
+ return i;
465
642
  }
643
+ }
644
+ return -1;
645
+ }
466
646
 
467
- const idx = items.indexOf(focused);
468
- let handled = true;
647
+ /**
648
+ * Repaint the tree because its window changed.
649
+ *
650
+ * Deferred to a microtask so a scroll arriving mid-commit cannot re-enter the render producing the
651
+ * rows, and skipped during a drag: `registerFileTreeDnD` holds pragmatic-dnd registrations on the
652
+ * rows it can see, and re-rendering under an active drag would drop every one of them.
653
+ */
654
+ function fileWindowChanged(): void {
655
+ if (_fileList?.isConnected !== true || _fileList.querySelector(".file-tree-item.dragging")) {
656
+ return;
657
+ }
658
+ queueMicrotask(() => _filesRerender?.());
659
+ }
469
660
 
470
- switch (e.key) {
471
- case "ArrowDown": {
472
- if (idx < items.length - 1) {
473
- items[idx + 1]!.focus();
474
- }
475
- break;
476
- }
477
- case "ArrowUp": {
478
- if (idx > 0) {
479
- items[idx - 1]!.focus();
480
- }
481
- break;
482
- }
483
- case "ArrowRight": {
484
- if (focused.dataset.type === "directory") {
485
- const path = focused.dataset.path as string;
486
- if (!requireProjectState().expanded.has(path)) {
487
- requireProjectState().expanded.add(path);
488
- void loadDirectory(path).then(() => {
489
- const panel = tree.closest(".panel-body");
490
- if (panel) {
491
- (panel.querySelector(".file-tree-item:focus") as HTMLElement | null)?.click();
492
- }
493
- });
494
- }
661
+ /**
662
+ * Adopt the rendered tree: remember it, keep it watching whatever scrolls it, and hand the keyboard
663
+ * the row a jump asked for once that row exists.
664
+ *
665
+ * The first paint of a session draws every row, because nothing can be measured before it exists;
666
+ * the watch's opening measurement is what asks for the second, windowed pass. Idempotent by
667
+ * construction — `watchListWindow` hands back the same watch for the same element and scroller — so
668
+ * calling it after every render costs a comparison.
669
+ */
670
+ function adoptFileTree(tree: HTMLElement): void {
671
+ _fileList = tree;
672
+ _fileWatch = watchListWindow(_fileWatch, tree, {
673
+ count: () => _fileRows.length,
674
+ onChange: fileWindowChanged,
675
+ rowHeight: fileRowHeight,
676
+ });
677
+ const wanted = _pendingFocusPath;
678
+ if (wanted !== null) {
679
+ // One shot: a focus request that outlived its own repaint is stale, and moving the keyboard
680
+ // Later is worse than never having moved it.
681
+ _pendingFocusPath = null;
682
+ fileRowElement(tree, wanted)?.focus();
683
+ }
684
+ }
685
+
686
+ /**
687
+ * Adopt the tree once its rows are in the document.
688
+ *
689
+ * Deferred by a microtask on purpose, exactly as the Outline's `afterTreeRender` is: a `ref` on the
690
+ * tree element commits BEFORE the child part holding the rows, so on a first render the callback
691
+ * would otherwise measure an empty tree.
692
+ */
693
+ function afterFileTreeRender(tree: HTMLElement): void {
694
+ queueMicrotask(() => adoptFileTree(tree));
695
+ }
696
+
697
+ /** The rendered row for a path, or null when the window does not currently hold it. */
698
+ function fileRowElement(tree: HTMLElement, path: string): HTMLElement | null {
699
+ return tree.querySelector<HTMLElement>(`.file-tree-item[data-path="${CSS.escape(path)}"]`);
700
+ }
701
+
702
+ /**
703
+ * The tree's rows, windowed, with the scroll height of the rows it left out reserved either side.
704
+ *
705
+ * The two spacers are `aria-hidden` because a `role="tree"` owns `treeitem`s, and an empty div that
706
+ * exists to be 4 000 pixels tall is not one.
707
+ */
708
+ function fileTreeBodyTemplate(ctx: {
709
+ openFileFn: (path: string) => void;
710
+ renderLeftPanel: () => void;
711
+ }): TemplateResult {
712
+ // The scroll watch outlives this call and must never repaint through a closure from an earlier
713
+ // One — the Navigator's scheduler is the only thing that knows how to draw this panel.
714
+ _filesRerender = ctx.renderLeftPanel;
715
+ _fileRows = [];
716
+ // Resolved once for the whole tree, not once per row: this function runs on EVERY repaint of the
717
+ // Navigator, and `resolveI18n` canonicalizes every declared tag through `Intl.Locale`.
718
+ const { i18n } = resolveI18n(projectState?.projectConfig ?? {});
719
+ collectFileRows(".", 0, _fileRows, ctx, i18n);
720
+ // Windowed against the PREVIOUS render's element, the only one that exists while this template is
721
+ // Being built. There is none on the first paint, and `listWindow` then answers "all of them".
722
+ const range = listWindow(_fileList, { count: _fileRows.length, rowHeight: fileRowHeight() });
723
+ // The roving tab stop is decided from the MODEL — the first DRAWN row of a windowed tree is
724
+ // Usually not the first row of the tree — and then clamped INTO the window, because a tab stop
725
+ // That is not in the document is not a tab stop: a tree whose selected row has scrolled away
726
+ // Would otherwise have no tabbable row at all, and Tab would skip the whole panel.
727
+ const wanted = Math.max(0, fileIndexOfPath(requireProjectState().selectedPath ?? undefined));
728
+ const tabStop = Math.min(Math.max(wanted, range.start), Math.max(range.start, range.end - 1));
729
+ const slice = _fileRows
730
+ .slice(range.start, range.end)
731
+ .map((row, offset) => ({ row, tabStop: range.start + offset === tabStop }));
732
+ return html`
733
+ <div style="height:${range.padTop}px" aria-hidden="true"></div>
734
+ ${repeat(
735
+ slice,
736
+ (entry) => entry.row.key,
737
+ (entry) => fileRowTemplate(entry.row, entry.tabStop, ctx),
738
+ )}
739
+ <div style="height:${range.padBottom}px" aria-hidden="true"></div>
740
+ `;
741
+ }
742
+
743
+ /** One row, drawn. */
744
+ function fileRowTemplate(
745
+ row: FileRow,
746
+ tabStop: boolean,
747
+ ctx: { openFileFn: (path: string) => void; renderLeftPanel: () => void },
748
+ ): TemplateResult {
749
+ if (row.loading) {
750
+ return html`<div
751
+ class="file-tree-item"
752
+ style="padding-left:${8 + row.depth * 16}px;color:var(--fg-dim);font-style:italic"
753
+ >
754
+ Loading…
755
+ </div>`;
756
+ }
757
+ const isDir = row.type === "directory";
758
+ return html`
759
+ <div
760
+ class=${classMap({
761
+ "file-tree-item": true,
762
+ selected: requireProjectState().selectedPath === row.path,
763
+ })}
764
+ style="padding-left:${8 + row.depth * 16}px"
765
+ role="treeitem"
766
+ aria-level=${row.depth + 1}
767
+ aria-posinset=${row.posInSet}
768
+ aria-setsize=${row.setSize}
769
+ tabindex=${tabStop ? "0" : "-1"}
770
+ data-path=${row.path}
771
+ data-type=${row.type}
772
+ aria-expanded=${isDir ? String(row.expanded) : nothing}
773
+ @click=${async (e: MouseEvent) => {
774
+ e.stopPropagation();
775
+ if (isDir) {
776
+ await toggleTreeDirectory(row.path);
777
+ ctx.renderLeftPanel();
778
+ } else {
779
+ ctx.openFileFn(row.path);
495
780
  }
496
- break;
781
+ }}
782
+ @contextmenu=${(e: MouseEvent) => {
783
+ e.preventDefault();
784
+ e.stopPropagation();
785
+ showFileContextMenu(e, { name: row.name, path: row.path, type: row.type }, ctx);
786
+ }}
787
+ >
788
+ ${
789
+ isDir
790
+ ? html`<span class="file-tree-toggle">${row.expanded ? "▼" : "▶"}</span>`
791
+ : html`<span class="file-tree-toggle empty"> </span>`
497
792
  }
498
- case "ArrowLeft": {
499
- if (focused.dataset.type === "directory") {
500
- const path = focused.dataset.path as string;
501
- if (requireProjectState().expanded.has(path)) {
502
- requireProjectState().expanded.delete(path);
503
- // RenderLeftPanel will be called by the caller who sets up keyboard
504
- }
505
- }
506
- break;
793
+ <span class="file-tree-icon">${fileTypeIconTpl(row.path, row.type)}</span>
794
+ <span class="file-tree-name">${row.name}</span>
795
+ ${
796
+ row.locale === undefined
797
+ ? nothing
798
+ : html`<span class="file-tree-locale">${localeLabel(row.locale)}</span>`
507
799
  }
508
- case "Enter": {
509
- focused.click();
510
- break;
800
+ </div>
801
+ `;
802
+ }
803
+
804
+ /** Expand or collapse one directory, listing it the first time it is opened. */
805
+ async function toggleTreeDirectory(path: string): Promise<void> {
806
+ const state = requireProjectState();
807
+ if (state.expanded.has(path)) {
808
+ state.expanded.delete(path);
809
+ return;
810
+ }
811
+ state.expanded.add(path);
812
+ if (!state.dirs.has(path)) {
813
+ await loadDirectory(path);
814
+ }
815
+ }
816
+
817
+ /** Tree elements this module has already given a keydown listener. */
818
+ const _keyboardTrees = new WeakSet<HTMLElement>();
819
+
820
+ /**
821
+ * Give the tree its keyboard, once per tree ELEMENT.
822
+ *
823
+ * The panel's `afterRender` calls this after every repaint and lit re-uses the `.file-tree` element
824
+ * across all of them, so the unguarded `addEventListener` it replaces accumulated one listener per
825
+ * render — after ten repaints a single Down keystroke walked ten rows. The WeakSet still lets a
826
+ * remounted panel's new element bind its own.
827
+ */
828
+ export function setupTreeKeyboard(tree: HTMLElement) {
829
+ if (_keyboardTrees.has(tree)) {
830
+ return;
831
+ }
832
+ _keyboardTrees.add(tree);
833
+ tree.addEventListener("keydown", (e: KeyboardEvent) => {
834
+ onFileTreeKeydown(e, tree);
835
+ });
836
+ }
837
+
838
+ /**
839
+ * The tree's keyboard model: ↑↓ walk the rows, → expands, ← collapses, Enter opens.
840
+ *
841
+ * ↑ and ↓ step through the MODEL, not through the rendered rows. A DOM-indexed walk stopped dead at
842
+ * the last row of the window — three rows past the bottom of the viewport, with thousands of files
843
+ * still below it — because there was simply no next element to focus. When the step lands outside
844
+ * the window the scroller moves instead, and the focus follows on the repaint ({@link
845
+ * adoptFileTree}).
846
+ */
847
+ function onFileTreeKeydown(e: KeyboardEvent, tree: HTMLElement): void {
848
+ const focused = tree.querySelector(".file-tree-item:focus") as HTMLElement | null;
849
+ if (!focused) {
850
+ return;
851
+ }
852
+ let handled = true;
853
+
854
+ switch (e.key) {
855
+ case "ArrowDown":
856
+ case "ArrowUp": {
857
+ const from = fileIndexOfPath(focused.dataset.path);
858
+ focusFileRow(tree, fileStep(from, e.key === "ArrowDown" ? 1 : -1));
859
+ break;
860
+ }
861
+ case "ArrowRight": {
862
+ const path = collapsedDirectoryAt(focused);
863
+ if (path !== null) {
864
+ // The expansion repaints THROUGH the panel. It used to synthesise a click on the focused
865
+ // Row to get a repaint, which ran that row's own toggle a second time and only did the
866
+ // Right thing because the handler's captured `isExpanded` was already stale.
867
+ void toggleTreeDirectory(path).then(() => _filesRerender?.());
511
868
  }
512
- default: {
513
- handled = false;
869
+ break;
870
+ }
871
+ case "ArrowLeft": {
872
+ const path = expandedDirectoryAt(focused);
873
+ if (path !== null) {
874
+ requireProjectState().expanded.delete(path);
875
+ // It used to leave the repaint to "the caller who sets up keyboard", and there was no such
876
+ // Caller: ← changed the state and left the children on screen until something else
877
+ // Happened to redraw the panel.
878
+ _filesRerender?.();
514
879
  }
880
+ break;
515
881
  }
516
- if (handled) {
517
- e.preventDefault();
882
+ case "Enter": {
883
+ focused.click();
884
+ break;
518
885
  }
519
- });
886
+ default: {
887
+ handled = false;
888
+ }
889
+ }
890
+ if (handled) {
891
+ e.preventDefault();
892
+ }
893
+ }
894
+
895
+ /** The row's directory path when it is a COLLAPSED directory — what → acts on, and nothing else. */
896
+ function collapsedDirectoryAt(row: HTMLElement): string | null {
897
+ const { path, type } = row.dataset;
898
+ return type === "directory" && path !== undefined && !requireProjectState().expanded.has(path)
899
+ ? path
900
+ : null;
901
+ }
902
+
903
+ /** The row's directory path when it is an EXPANDED directory — what ← acts on, and nothing else. */
904
+ function expandedDirectoryAt(row: HTMLElement): string | null {
905
+ const { path, type } = row.dataset;
906
+ return type === "directory" && path !== undefined && requireProjectState().expanded.has(path)
907
+ ? path
908
+ : null;
909
+ }
520
910
 
521
- // Set first item focusable
522
- const first = tree.querySelector(".file-tree-item");
523
- if (first) {
524
- first.setAttribute("tabindex", "0");
911
+ /** Move the keyboard to the model row at `index`, bringing it into the window if it is outside. */
912
+ function focusFileRow(tree: HTMLElement, index: number): void {
913
+ const row = _fileRows[index];
914
+ if (!row) {
915
+ return;
916
+ }
917
+ const el = fileRowElement(tree, row.path);
918
+ if (el) {
919
+ el.focus();
920
+ return;
921
+ }
922
+ if (revealListRow(_fileList, index, fileRowHeight())) {
923
+ _pendingFocusPath = row.path;
924
+ _filesRerender?.();
525
925
  }
526
926
  }
527
927
 
@@ -804,9 +1204,13 @@ async function moveFileEntry(oldPath: string, newPath: string, renderLeftPanel:
804
1204
 
805
1205
  reloadRewrittenTabs(report, newPath);
806
1206
  renderLeftPanel();
807
- statusMessage(`Moved to ${newPath}`);
1207
+ notify.success(`Moved to ${newPath}`);
808
1208
  } catch (error) {
809
- statusMessage(`Error: ${errorMessage(error)}`);
1209
+ notify.error(`Could not move ${oldPath}.`, {
1210
+ detail: errorMessage(error),
1211
+ path: oldPath,
1212
+ source: "Files",
1213
+ });
810
1214
  }
811
1215
  }
812
1216
 
@@ -828,6 +1232,89 @@ function dismissFileContextMenu() {
828
1232
  }
829
1233
  }
830
1234
 
1235
+ /** One row of the file menu. The divider is the em-dash label; `disabled` rows explain themselves. */
1236
+ interface FileMenuItem {
1237
+ label: string;
1238
+ action?: () => void;
1239
+ danger?: boolean;
1240
+ disabled?: boolean;
1241
+ /** The `requires` sentence, printed under a disabled row. */
1242
+ reason?: string;
1243
+ }
1244
+
1245
+ /**
1246
+ * A file row addresses ONE thing, and this is everything it can say about it — keyed by the
1247
+ * ARGUMENT NAME that asks for it.
1248
+ *
1249
+ * `menus: ["context/file"]` names a placement, and a placement nothing renders is the same defect
1250
+ * as a command nothing registers, one layer down: `content.openEntry` declared this menu and the
1251
+ * tree drew a hand-built list beside it, so the row simply never existed. Rendering the placement
1252
+ * is the fix, and it needs the one thing the element menu does not — an argument.
1253
+ * `content.openEntry` wants a `path`, `collection.editInGrid` wants a `name`, and only the row
1254
+ * knows either.
1255
+ *
1256
+ * A fact is stated ONLY when it is true of this row, and that is what decides whether a command
1257
+ * appears at all: `styles/main.css` is an entry of no collection, so it states no `path`, so "Open
1258
+ * Entry Form" is not offered on it. A command whose required arguments this row cannot answer is
1259
+ * skipped — never rendered into a refusal the author cannot act on.
1260
+ */
1261
+ function fileRowFacts(entry: { path: string; type: string }): Record<string, unknown> {
1262
+ const facts: Record<string, unknown> = {};
1263
+ if (entry.type === "directory") {
1264
+ const collection = collectionDirs().find(
1265
+ ({ dir }) => entry.path === dir || entry.path.endsWith(`/${dir}`),
1266
+ );
1267
+ if (collection) {
1268
+ facts.name = collection.name;
1269
+ }
1270
+ } else if (collectionOfPath(entry.path)) {
1271
+ facts.path = entry.path;
1272
+ }
1273
+ return facts;
1274
+ }
1275
+
1276
+ /**
1277
+ * The declared `context/file` commands this row can offer.
1278
+ *
1279
+ * Everything a row prints comes off the record — its title, its position (`forPlacement` sorts by
1280
+ * `group`), whether it is enabled and the sentence saying why not. Nothing here names a command, so
1281
+ * a new `context/file` record appears in the tree with no edit to this file.
1282
+ */
1283
+ function placedFileItems(entry: { path: string; type: string }): FileMenuItem[] {
1284
+ const registry = activeRegistry();
1285
+ if (!registry) {
1286
+ return [];
1287
+ }
1288
+ const facts = fileRowFacts(entry);
1289
+ const items: FileMenuItem[] = [];
1290
+ for (const command of registry.forPlacement("context/file")) {
1291
+ const schema = command.args as
1292
+ | { properties?: Record<string, unknown>; required?: readonly string[] }
1293
+ | undefined;
1294
+ if (!(schema?.required ?? []).every((key) => key in facts)) {
1295
+ continue;
1296
+ }
1297
+ const args: Record<string, unknown> = {};
1298
+ for (const key of Object.keys(schema?.properties ?? {})) {
1299
+ if (key in facts) {
1300
+ args[key] = facts[key];
1301
+ }
1302
+ }
1303
+ const reason = registry.disabledReason(command.id);
1304
+ items.push(
1305
+ reason === undefined
1306
+ ? {
1307
+ action: () => {
1308
+ void registry.run(command.id, args);
1309
+ },
1310
+ label: command.title,
1311
+ }
1312
+ : { disabled: true, label: command.title, reason },
1313
+ );
1314
+ }
1315
+ return items;
1316
+ }
1317
+
831
1318
  function showFileContextMenu(
832
1319
  e: MouseEvent,
833
1320
  entry: { name: string; path: string; type: string },
@@ -837,8 +1324,7 @@ function showFileContextMenu(
837
1324
  dismissFileContextMenu();
838
1325
  const isDir = entry.type === "directory";
839
1326
 
840
- /** @type {{ label: string; action?: () => void; danger?: boolean }[]} */
841
- const items = [];
1327
+ const items: FileMenuItem[] = [];
842
1328
 
843
1329
  if (!isDir) {
844
1330
  items.push({ action: () => ctx.openFileFn(entry.path), label: "Open" });
@@ -854,19 +1340,9 @@ function showFileContextMenu(
854
1340
  label: "Upload Files\u2026",
855
1341
  },
856
1342
  );
857
- // Directories backing a content collection get a bulk-edit affordance.
858
- const collection = collectionDirs().find(
859
- ({ dir }) => entry.path === dir || entry.path.endsWith(`/${dir}`),
860
- );
861
- if (collection) {
862
- items.push({
863
- action: () => {
864
- openCollectionGrid(collection.name);
865
- },
866
- label: "Edit Collection in Grid",
867
- });
868
- }
869
1343
  if (entry.path === "pages" || entry.path.endsWith("/pages")) {
1344
+ // The one hand-built row left: no command declares "open the pages grid". `grid-open.ts`'s
1345
+ // `collection.editInGrid` has no pages sibling, so there is nothing here to render yet.
870
1346
  items.push({
871
1347
  action: () => {
872
1348
  openPagesGrid();
@@ -876,6 +1352,9 @@ function showFileContextMenu(
876
1352
  }
877
1353
  }
878
1354
  items.push(
1355
+ // The declared rows sit between what the TREE does to a file (open it, create in it, upload to
1356
+ // It) and what it does to the file's existence (rename, delete).
1357
+ ...placedFileItems(entry),
879
1358
  { label: "\u2014" },
880
1359
  {
881
1360
  action: () => renameFile(entry, ctx.renderLeftPanel),
@@ -888,8 +1367,8 @@ function showFileContextMenu(
888
1367
  },
889
1368
  );
890
1369
 
891
- let x = e.clientX,
892
- y = e.clientY;
1370
+ let x = e.clientX;
1371
+ let y = e.clientY;
893
1372
 
894
1373
  _fileCtxHandle = renderPopover(
895
1374
  html`<sp-popover
@@ -919,11 +1398,20 @@ function showFileContextMenu(
919
1398
  ? html`<sp-menu-divider></sp-menu-divider>`
920
1399
  : html`<sp-menu-item
921
1400
  style=${item.danger ? "color: var(--danger)" : ""}
1401
+ ?disabled=${item.disabled === true}
1402
+ aria-disabled=${item.disabled === true ? "true" : "false"}
922
1403
  @click=${() => {
1404
+ if (item.disabled === true) {
1405
+ return;
1406
+ }
923
1407
  dismissFileContextMenu();
924
1408
  void item.action?.();
925
1409
  }}
926
- >${item.label}</sp-menu-item
1410
+ >${item.label}${
1411
+ // A disabled row says what it needs, the same sentence the palette and the agent
1412
+ // Print — `requires`, off the record, never re-worded here.
1413
+ item.reason ? html`<span slot="description">Needs ${item.reason}</span>` : nothing
1414
+ }</sp-menu-item
927
1415
  >`,
928
1416
  )}
929
1417
  </sp-menu>
@@ -939,42 +1427,141 @@ function showFileContextMenu(
939
1427
 
940
1428
  // ─── File CRUD ────────────────────────────────────────────────────────────────
941
1429
 
942
- async function createNewFile(dirPath: string, renderLeftPanel: () => void) {
943
- const name = await showPromptDialog("New File", {
1430
+ /** The default body for a path no format claims — a document, since that is what Jx authors. */
1431
+ const BLANK_DOCUMENT = JSON.stringify(
1432
+ { children: [{ children: [], tagName: "p" }], tagName: "div" },
1433
+ null,
1434
+ 2,
1435
+ );
1436
+
1437
+ /**
1438
+ * One creation, named.
1439
+ *
1440
+ * `dir` is required and has no default. That is the whole point of the type: the Library used to
1441
+ * derive its destination from whichever CATEGORY filter happened to be active — and "All" derived
1442
+ * nothing, so a new page landed wherever the writer's fallback pointed. A creation flow that cannot
1443
+ * say where the file is going has no business asking for its name.
1444
+ */
1445
+ export interface NewFileRequest {
1446
+ /** Destination directory, project-relative. `"."` is the project root. */
1447
+ dir: string;
1448
+ /** Dialog title — "New File", "New Page", "New Post". Defaults to "New File". */
1449
+ title?: string;
1450
+ /** Pre-filled value; its stem is selected so typing replaces the name and keeps the extension. */
1451
+ suggestedName?: string;
1452
+ /**
1453
+ * When set, the field asks for a DISPLAY NAME and this extension is appended to its slug — "My
1454
+ * First Post" becomes `my-first-post.md`. When absent, the field asks for a file name and takes
1455
+ * it verbatim, which is what the Files tree has always done.
1456
+ */
1457
+ ext?: string;
1458
+ /** Body to write. Defaults to the resolved format's `newFileTemplate`. */
1459
+ content?: string;
1460
+ /** Who is creating, for the Problem's `source` line. Defaults to "Files". */
1461
+ source?: string;
1462
+ }
1463
+
1464
+ /**
1465
+ * Create one file, from the one flow both the Files tree and the Library use.
1466
+ *
1467
+ * Two behaviours the callers used to disagree about, settled here:
1468
+ *
1469
+ * - **A name that is already taken is refused in the FIELD**, not discovered afterwards. Both
1470
+ * predecessors called `writeFile` straight onto the composed path, so creating `about.md` in a
1471
+ * directory that had one silently replaced it — with no undo, because the file was never open.
1472
+ * The destination is listed once before the prompt so `validate` can say so while it can still be
1473
+ * fixed.
1474
+ * - **A failure is a Problem carrying the path**, not a toast that scrolls away, since the thing the
1475
+ * author must do next is about that path.
1476
+ *
1477
+ * @returns The created path, or `null` when the author cancelled or the write failed.
1478
+ */
1479
+ export async function createFileIn(request: NewFileRequest): Promise<string | null> {
1480
+ const { dir, ext, source = "Files" } = request;
1481
+ await loadFormats();
1482
+
1483
+ // One listing, before the field opens: the names it must refuse are known while typing.
1484
+ let taken = new Set<string>();
1485
+ try {
1486
+ const listing = await getPlatform().listDirectory(dir);
1487
+ taken = new Set(listing.map((entry) => entry.name));
1488
+ } catch {
1489
+ // A directory that cannot be listed is usually one that does not exist yet — the write below
1490
+ // Is the authority on whether that is a problem, and it reports with the real reason.
1491
+ }
1492
+
1493
+ const fileNameFor = (input: string) =>
1494
+ ext === undefined
1495
+ ? input.trim()
1496
+ : `${input
1497
+ .trim()
1498
+ .toLowerCase()
1499
+ .replaceAll(/\s+/g, "-")
1500
+ .replaceAll(/[^a-z\d-]/g, "")}${ext}`;
1501
+
1502
+ const entered = await showPromptDialog(request.title ?? "New File", {
944
1503
  confirmLabel: "Create",
945
- message: dirPath === "." ? "Creating in the project root." : `Creating in ${dirPath}/`,
946
- placeholder: "untitled.json",
1504
+ message: dir === "." ? "Creating in the project root." : `Creating in ${dir}/`,
1505
+ ...(request.suggestedName === undefined ? {} : { placeholder: request.suggestedName }),
947
1506
  select: "stem",
948
- validate: (v) => (v.trim() ? "" : "Enter a file name."),
949
- value: "untitled.json",
1507
+ validate: (value) => {
1508
+ if (!value.trim()) {
1509
+ return ext === undefined ? "Enter a file name." : "Enter a name.";
1510
+ }
1511
+ const candidate = fileNameFor(value);
1512
+ if (!candidate || candidate === ext) {
1513
+ return "Enter at least one letter or number.";
1514
+ }
1515
+ return taken.has(candidate) ? `${candidate} already exists in ${dir}/.` : "";
1516
+ },
1517
+ value: request.suggestedName ?? "untitled.json",
950
1518
  });
951
- if (!name) {
952
- return;
1519
+ if (!entered) {
1520
+ return null;
953
1521
  }
954
- const path = dirPath === "." ? name : `${dirPath}/${name}`;
1522
+
1523
+ const fileName = fileNameFor(entered);
1524
+ const path = dir === "." ? fileName : `${dir}/${fileName}`;
955
1525
  markLocalMutation(path);
956
- await loadFormats();
957
- const format = formatForPath(name);
1526
+ const format = formatForPath(fileName);
958
1527
  const content =
959
- format?.studio?.newFileTemplate ??
960
- (format
961
- ? ""
962
- : JSON.stringify({ children: [{ children: [], tagName: "p" }], tagName: "div" }, null, 2));
1528
+ request.content ?? format?.studio?.newFileTemplate ?? (format ? "" : BLANK_DOCUMENT);
963
1529
  try {
964
- const platform = getPlatform();
965
- await platform.writeFile(path, content);
966
- await loadDirectory(dirPath);
967
- renderLeftPanel();
968
- statusMessage(`Created ${path}`);
1530
+ await getPlatform().writeFile(path, content);
1531
+ await loadDirectory(dir);
1532
+ notify.success(`Created ${path}`);
1533
+ return path;
969
1534
  } catch (error) {
970
- statusMessage(`Error: ${errorMessage(error)}`);
1535
+ notify.error(`Could not create ${path}.`, {
1536
+ detail: errorMessage(error),
1537
+ path,
1538
+ source,
1539
+ });
1540
+ return null;
1541
+ }
1542
+ }
1543
+
1544
+ async function createNewFile(dirPath: string, renderLeftPanel: () => void) {
1545
+ const created = await createFileIn({ dir: dirPath, suggestedName: "untitled.json" });
1546
+ if (created !== null) {
1547
+ renderLeftPanel();
971
1548
  }
972
1549
  }
973
1550
 
974
- /** @param {string} currentName @returns {Promise<string | null>} */
975
- function showRenameFileDialog(currentName: string): Promise<string | null> {
1551
+ /**
1552
+ * The rename dialog, carrying what moves with the file.
1553
+ *
1554
+ * `renamePromptMessage` is awaited before the field opens, so the count is on screen when the name
1555
+ * is typed rather than after it is confirmed — the refactor pass is about to rewrite every one of
1556
+ * those references, and silently doing that much work was the previous behaviour.
1557
+ *
1558
+ * @param {string} currentName @param {string} path @returns {Promise<string | null>}
1559
+ */
1560
+ async function showRenameFileDialog(currentName: string, path: string): Promise<string | null> {
1561
+ const message = await renamePromptMessage(path);
976
1562
  return showPromptDialog("Rename", {
977
1563
  confirmLabel: "Rename",
1564
+ ...(message === undefined ? {} : { message }),
978
1565
  select: "stem",
979
1566
  validate: (v) => (v.trim() ? "" : "Enter a file name."),
980
1567
  value: currentName,
@@ -1007,7 +1594,7 @@ async function renameFile(
1007
1594
  entry: { name: string; path: string; type: string },
1008
1595
  renderLeftPanel: () => void,
1009
1596
  ) {
1010
- const newName = await showRenameFileDialog(entry.name);
1597
+ const newName = await showRenameFileDialog(entry.name, entry.path);
1011
1598
  if (!newName || newName === entry.name) {
1012
1599
  return;
1013
1600
  }
@@ -1020,6 +1607,9 @@ async function renameFile(
1020
1607
  try {
1021
1608
  const platform = getPlatform();
1022
1609
  const report = await platform.renameFile(entry.path, newPath);
1610
+ // The refactor pass just rewrote references project-wide, and `markLocalMutation` suppresses
1611
+ // The watcher echo that would otherwise say so — hence the explicit drop.
1612
+ invalidateUsages();
1023
1613
  await loadDirectory(parentDirPath);
1024
1614
  if (requireProjectState().selectedPath === entry.path) {
1025
1615
  requireProjectState().selectedPath = newPath;
@@ -1029,9 +1619,13 @@ async function renameFile(
1029
1619
  }
1030
1620
  reloadRewrittenTabs(report, newPath);
1031
1621
  renderLeftPanel();
1032
- statusMessage(renameStatus(newName, report));
1622
+ notify.success(renameStatus(newName, report));
1033
1623
  } catch (error) {
1034
- statusMessage(`Error: ${errorMessage(error)}`);
1624
+ notify.error(`Could not rename ${entry.name}.`, {
1625
+ detail: errorMessage(error),
1626
+ path: entry.path,
1627
+ source: "Files",
1628
+ });
1035
1629
  }
1036
1630
  }
1037
1631
 
@@ -1039,10 +1633,7 @@ async function deleteFile(
1039
1633
  entry: { name: string; path: string; type: string },
1040
1634
  renderLeftPanel: () => void,
1041
1635
  ) {
1042
- const confirmed = await showConfirmDialog("Delete File", `Delete "${entry.name}"?`, {
1043
- confirmLabel: "Delete",
1044
- destructive: true,
1045
- });
1636
+ const confirmed = await confirmFileDelete(entry);
1046
1637
  if (!confirmed) {
1047
1638
  return;
1048
1639
  }
@@ -1050,6 +1641,7 @@ async function deleteFile(
1050
1641
  const platform = getPlatform();
1051
1642
  markLocalMutation(entry.path);
1052
1643
  await platform.deleteFile(entry.path);
1644
+ invalidateUsages();
1053
1645
  const delPath = entry.path.replaceAll("\\", "/");
1054
1646
  const parentDirPath = delPath.includes("/") ? delPath.slice(0, delPath.lastIndexOf("/")) : ".";
1055
1647
  await loadDirectory(parentDirPath);
@@ -1057,96 +1649,77 @@ async function deleteFile(
1057
1649
  requireProjectState().selectedPath = null;
1058
1650
  }
1059
1651
  renderLeftPanel();
1060
- statusMessage(`Deleted ${entry.name}`);
1652
+ notify.success(`Deleted ${entry.name}`);
1061
1653
  } catch (error) {
1062
- statusMessage(`Error: ${errorMessage(error)}`);
1654
+ notify.error(`Could not delete ${entry.name}.`, {
1655
+ detail: errorMessage(error),
1656
+ path: entry.path,
1657
+ source: "Files",
1658
+ });
1063
1659
  }
1064
1660
  }
1065
1661
 
1066
- // ─── Open file from tree ──────────────────────────────────────────────────────
1067
-
1068
1662
  /**
1069
- * Open a file from the file tree — auto-saves current dirty doc, then loads the new one.
1663
+ * What an open can say beyond the path. Every field defaults to today's answer.
1070
1664
  *
1071
- * @param {{
1072
- * S: import("../state.js").StudioState;
1073
- * commit: (s: import("../state.js").StudioState) => void;
1074
- * render: () => void;
1075
- * loadMarkdown: (source: string, handle: unknown) => void;
1076
- * }} ctx
1077
- * @param {string} path
1665
+ * Deliberately one inline-typed `opts` object rather than a `paneId` parameter:
1666
+ * `scripts/check-pane-singletons.ts` rule 4 charges any function whose parameters NAME a pane for
1667
+ * reading the focus, one hop in — and {@link openFileInTab} legitimately falls back to the focused
1668
+ * pane when nobody names one. {@link openFileInPane} is the named sibling for readers who want the
1669
+ * pane in the signature; it is pane-scoped and reads no focus of its own.
1078
1670
  */
1079
- export async function openFileFromTree(
1080
- ctx: {
1081
- S: StudioState;
1082
- commit: (s: StudioState) => void;
1083
- render: () => void;
1084
- loadMarkdown: (source: string, handle: unknown) => void;
1085
- },
1086
- path: string,
1087
- ) {
1088
- const platform = getPlatform();
1089
- await loadFormats();
1090
- // Auto-save current dirty document
1091
- if (ctx.S.dirty && ctx.S.documentPath) {
1092
- try {
1093
- const tabLike = {
1094
- doc: {
1095
- content: ctx.S.content,
1096
- document: ctx.S.document,
1097
- mode: ctx.S.mode,
1098
- sourceFormat: formatForPath(ctx.S.documentPath)?.name ?? null,
1099
- },
1100
- } as unknown as Tab;
1101
- const output = await serializeDocument(tabLike);
1102
- await platform.writeFile(ctx.S.documentPath, output);
1103
- } catch (error) {
1104
- statusMessage(`Save error: ${errorMessage(error)}`);
1105
- }
1106
- }
1671
+ export interface OpenFileOpts {
1672
+ /** Which pane. Defaults to the focused one. */
1673
+ paneId?: string;
1674
+ /** Open as a disposable preview tab (§4.3) — browsing rather than committing. */
1675
+ preview?: boolean;
1676
+ /** False leaves the keyboard where it is. Defaults to true. */
1677
+ focus?: boolean;
1678
+ }
1107
1679
 
1108
- // Fetch the file
1109
- try {
1110
- const content = await platform.readFile(path);
1111
- if (!content) {
1680
+ /**
1681
+ * Bring an ALREADY-OPEN tab to where the caller asked for it.
1682
+ *
1683
+ * Three cases, and the third is the one a following pane depends on:
1684
+ *
1685
+ * | the tab is… | behaviour |
1686
+ * | ------------------------------------------------ | ---------------------------------------------- |
1687
+ * | in the requested pane (or no pane was requested) | activate it there, honouring `focus` |
1688
+ * | elsewhere, and **not** its pane's active tab | move it — one tab is one document in one strip |
1689
+ * | elsewhere, and **is** its pane's active tab | **nothing.** You are already looking at it |
1690
+ *
1691
+ * The third exists because moving it would oscillate: a derivation that re-resolves to the document
1692
+ * the author is editing would yank it out of their pane and into the assistant one, and the follow
1693
+ * would then re-resolve against whatever landed in its place.
1694
+ */
1695
+ function revealOpenTab(tabId: string, opts: OpenFileOpts): void {
1696
+ const wanted = opts.paneId;
1697
+ const holder = paneOfTab(tabId);
1698
+ if (wanted !== undefined && holder && holder.id !== wanted) {
1699
+ if (holder.activeTabId === tabId) {
1112
1700
  return;
1113
1701
  }
1114
-
1115
- if (formatForPath(path)) {
1116
- ctx.loadMarkdown(content, null);
1117
- ctx.S.documentPath = path;
1118
- ctx.S.dirty = false;
1119
- ctx.commit(ctx.S);
1120
- } else if (path.endsWith(".json")) {
1121
- const doc = JSON.parse(content) as JxMutableNode;
1122
- const newS = createState(doc);
1123
- newS.documentPath = path;
1124
- newS.dirty = false;
1125
- ctx.commit(newS);
1126
- } else {
1127
- throw noFormatError(path);
1128
- }
1129
-
1130
- // Update tree selection
1131
- requireProjectState().selectedPath = path;
1132
-
1133
- ctx.render();
1134
- statusMessage(`Opened ${path}`);
1135
- } catch (error) {
1136
- statusMessage(`Error: ${errorMessage(error)}`);
1702
+ moveTabToPane(tabId, wanted);
1137
1703
  }
1704
+ activateTab(tabId, { focus: opts.focus !== false });
1138
1705
  }
1139
1706
 
1140
1707
  /**
1141
- * Open a file from the tree into a tab. Activates existing tab if already open.
1708
+ * Open a file from the tree into a tab. Activates the existing tab if it is already open.
1142
1709
  *
1143
1710
  * @param {string} path
1711
+ * @param {OpenFileOpts} [opts]
1144
1712
  */
1145
- export async function openFileInTab(path: string) {
1713
+ export async function openFileInTab(path: string, opts: OpenFileOpts = {}) {
1714
+ const follows = opts.focus !== false;
1146
1715
  for (const [id, tab] of workspace.tabs.entries()) {
1147
1716
  if (tab.documentPath === path) {
1148
- activateTab(id);
1149
- requireProjectState().selectedPath = path;
1717
+ revealOpenTab(id, opts);
1718
+ // The tree's cursor answers "where is the author", so a side-open that deliberately left the
1719
+ // Keyboard behind must not move it.
1720
+ if (follows) {
1721
+ requireProjectState().selectedPath = path;
1722
+ }
1150
1723
  return;
1151
1724
  }
1152
1725
  }
@@ -1161,9 +1734,12 @@ export async function openFileInTab(path: string) {
1161
1734
  path,
1162
1735
  root: requireProjectState().projectRoot,
1163
1736
  });
1164
- statusMessage(`Opened ${path.split("/").pop()}`);
1165
1737
  } catch (error) {
1166
- statusMessage(`Error: ${errorMessage(error)}`);
1738
+ notify.error(`Could not open ${path}.`, {
1739
+ detail: errorMessage(error),
1740
+ path,
1741
+ source: "Open File",
1742
+ });
1167
1743
  }
1168
1744
  return;
1169
1745
  }
@@ -1196,20 +1772,41 @@ export async function openFileInTab(path: string) {
1196
1772
  document,
1197
1773
  ...(frontmatter != null && { frontmatter }),
1198
1774
  sourceFormat: format?.name ?? null,
1775
+ ...(opts.paneId !== undefined && { paneId: opts.paneId }),
1776
+ ...(opts.preview === true && { preview: true }),
1777
+ ...(opts.focus === false && { focus: false }),
1199
1778
  });
1200
- requireProjectState().selectedPath = path;
1779
+ if (follows) {
1780
+ requireProjectState().selectedPath = path;
1781
+ }
1201
1782
  trackRecentFile({
1202
1783
  name: path.split("/").pop() || path,
1203
1784
  path,
1204
1785
  root: requireProjectState().projectRoot,
1205
1786
  });
1206
-
1207
- statusMessage(`Opened ${path.split("/").pop()}`);
1208
1787
  } catch (error) {
1209
- statusMessage(`Error: ${errorMessage(error)}`);
1788
+ notify.error(`Could not open ${path}.`, {
1789
+ detail: errorMessage(error),
1790
+ path,
1791
+ source: "Open File",
1792
+ });
1210
1793
  }
1211
1794
  }
1212
1795
 
1796
+ /**
1797
+ * Open a file into a NAMED pane, browsing rather than committing, leaving the keyboard behind.
1798
+ *
1799
+ * The same body as {@link openFileInTab} with the three options a side-open always wants, given a
1800
+ * signature that says which pane in the first parameter. It is what "open it beside this" means
1801
+ * everywhere it is asked for — drilling into a component, following a layout, `pane.compareWith`.
1802
+ *
1803
+ * @param {string} paneId
1804
+ * @param {string} path
1805
+ */
1806
+ export async function openFileInPane(paneId: string, path: string): Promise<void> {
1807
+ await openFileInTab(path, { focus: false, paneId, preview: true });
1808
+ }
1809
+
1213
1810
  /**
1214
1811
  * Reload an already-open tab from disk without changing the active tab. Used to refresh after AI
1215
1812
  * assistant writes to a file.
@@ -1249,8 +1846,41 @@ export async function reloadFileInTab(path: string) {
1249
1846
  tab.doc.document = JSON.parse(content) as JxMutableNode;
1250
1847
  }
1251
1848
  tab.doc.dirty = false;
1252
- } catch {}
1849
+ } catch (error) {
1850
+ // A file that changed on disk and cannot be re-read leaves the open tab showing the OLD
1851
+ // Document with no indication that it is stale — the most expensive silence in this file.
1852
+ notify.error(`Could not reload ${path} after it changed on disk.`, {
1853
+ detail: errorMessage(error),
1854
+ key: `reload:${path}`,
1855
+ path,
1856
+ source: "Files",
1857
+ });
1858
+ }
1253
1859
  return;
1254
1860
  }
1255
1861
  }
1256
1862
  }
1863
+
1864
+ /**
1865
+ * Contribute the Files panel.
1866
+ *
1867
+ * `level: "project"` because it WRITES project files — create, rename, delete, move. It reads the
1868
+ * focused document only to highlight a row, and principle 3 files a surface by what it writes.
1869
+ */
1870
+ export function registerFilesPanel(): void {
1871
+ registerPanel({
1872
+ id: "files",
1873
+ title: "Files",
1874
+ level: "project",
1875
+ dock: "navigator",
1876
+ icon: "sp-icon-folder",
1877
+ render: (ctx) => ctx.deps.renderFilesTemplate(),
1878
+ afterRender: (ctx, host) => {
1879
+ const tree = host.querySelector(".file-tree") as HTMLElement | null;
1880
+ if (tree) {
1881
+ ctx.deps.setupTreeKeyboard(tree);
1882
+ }
1883
+ ctx.deps.registerFileTreeDnD({ renderLeftPanel: ctx.rerender });
1884
+ },
1885
+ });
1886
+ }