@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
@@ -1,125 +1,465 @@
1
1
  /// <reference lib="dom" />
2
2
  /**
3
- * Tab strip — renders open tabs above the canvas area.
3
+ * Tab strip — one strip PER PANE, above each pane's editor.
4
4
  *
5
- * Uses the reactive workspace model: reads from workspace.tabOrder, workspace.tabs,
6
- * workspace.activeTabId. Clicks call activateTab/closeTab from workspace.js.
5
+ * A tab belongs to a pane and the pane is what splits (§4.3), so the strip is a rendering of
6
+ * `Pane.tabOrder` / `Pane.activeTabId`, not of a workspace-wide list. The host for each pane is
7
+ * addressed by REGION id — `pane.primary/tabs`, `pane.secondary/tabs` — rather than by element id,
8
+ * so the shell can move or rename the divs without touching this file.
9
+ *
10
+ * Six things the strip owes the author:
11
+ *
12
+ * - **A label that identifies the document.** A realistic Jx session has four tabs whose basename is
13
+ * `index.md`, so the label is the shortest unique path suffix — and for a page, its ROUTE, which
14
+ * is the name the author actually thinks in (`/blog/[slug]`, not a fourth `index.md`).
15
+ * - **A way to reach a tab that is off-screen.** The horizontal scrollbar is hidden by design and the
16
+ * wheel handler below is a mouse affordance, so a trackpad user with fifteen files open had no
17
+ * way at all. The overflow chevron lists every tab currently out of view.
18
+ * - **A pin**, so the four documents you keep coming back to hold the head of the strip and no
19
+ * preview open can take their slot.
20
+ * - **Drag reorder**, clamped so a drag can never interleave a pinned tab with an unpinned one.
21
+ * - **Preview tabs.** A single click from the tree or the palette opens ITALIC and replaceable. This
22
+ * is worth more in Jx than in VS Code, because the palette's `@`/`#` modes make browsing cheap
23
+ * and browsing must not litter. Committing — an edit, a pin, a double-click — makes the tab
24
+ * permanent.
25
+ * - **A context menu**, which is `registry.forPlacement("context/tab")` and nothing else. Six records
26
+ * declare that placement; until this menu existed, right-click — the gesture the placement is FOR
27
+ * — reached none of them. See {@link placedTabItems}.
7
28
  */
8
29
 
9
30
  import { html, render as litRender, nothing } from "lit-html";
10
31
  import { classMap } from "lit-html/directives/class-map.js";
11
32
  import { repeat } from "lit-html/directives/repeat.js";
12
33
  import { effect, effectScope } from "../reactivity";
13
- import { activateTab, closeTab, workspace } from "../workspace/workspace";
34
+ import {
35
+ activateTab,
36
+ closeTab,
37
+ focusPane,
38
+ isPaneFocused,
39
+ moveTab,
40
+ promoteDirtyPreviewTabs,
41
+ promoteTab,
42
+ setTabPinned,
43
+ workspace,
44
+ } from "../workspace/workspace";
14
45
  import { gridTabLabel } from "../grid/grid-source";
46
+ import { entryDraftPill } from "../content/entry-editor";
47
+ import { DRAFT_FIELD, isDraftEntry } from "../content/draft-state";
48
+ import { entryFields } from "../content/entry-fields";
49
+ import { collectionOfPath } from "../content/entry-model";
50
+ import { activeRegistry } from "../commands/active-registry";
51
+ import { PRESET_LABELS } from "../workspace/pane-derive";
52
+ import { localeLabel } from "@jxsuite/schema/locale";
53
+ import { tabOfPane } from "../canvas/canvas-surface";
54
+ import { mediaDisplayName } from "./shared";
55
+ import type { Pane, PaneDerivation } from "../workspace/workspace";
15
56
  import type { Tab } from "../tabs/tab";
16
- import { showConfirmDialog } from "../ui/layers";
57
+ import { renderPopover, showConfirmDialog, showSaveDiscardDialog } from "../ui/layers";
58
+ import { saveFile } from "../files/file-ops";
59
+ import { collabReadOnly } from "../collab/collab-session";
17
60
  import { collabState } from "../collab/collab-state";
61
+ import { rectOf } from "../utils/geometry";
62
+ import { resolveRegion } from "../ui/regions";
63
+ import { commitTabBuffers, tabBufferUnsaved } from "../services/monaco-buffer";
18
64
  import type { EffectScope } from "@vue/reactivity";
65
+ import type { TemplateResult } from "lit-html";
19
66
 
20
- let _host: HTMLElement | null = null;
67
+ /**
68
+ * The primary pane's host, as handed over by the shell's bootstrap.
69
+ *
70
+ * Every OTHER pane's host is resolved by region id at render time, because a pane can appear and
71
+ * disappear between renders and there is nothing to hand over when it does.
72
+ */
73
+ let _primaryHost: HTMLElement | null = null;
21
74
 
22
75
  let _scope: EffectScope | null = null;
23
76
 
24
- let _lastActiveId: string | null = null;
77
+ /** Last-rendered active tab per pane — decides when to scroll a chip into view. */
78
+ const _lastActive = new Map<string, string | null>();
79
+
80
+ /** The host each pane last drew into, so a strip can be blanked when the pane stops owning it. */
81
+ const _hosts = new Map<string, HTMLElement>();
82
+
83
+ /** Whether each pane's strip overflows — decides its chevron. Measured after each render. */
84
+ const _overflowing = new Map<string, boolean>();
85
+
86
+ let _overflowHandle: { dismiss: () => void } | null = null;
87
+
88
+ /** The tab id currently being dragged, or null. */
89
+ let _dragging: string | null = null;
90
+
91
+ /** Region id of a pane's tab strip. `ui/regions.ts` owns the grammar; this is one call site. */
92
+ export function paneStripRegion(paneId: string): string {
93
+ return `pane.${paneId}/tabs`;
94
+ }
95
+
96
+ /**
97
+ * Where a pane's strip renders, or null when the shell gives it nowhere.
98
+ *
99
+ * A pane with a region host of its own renders there. A pane WITHOUT one borrows the host the shell
100
+ * handed over — the primary's — because that div is the shell's single strip exactly as
101
+ * `#canvas-wrap` is its single stage. Two panes then name the same element, and {@link render}
102
+ * gives it to the FOCUSED one, the same handover `canvas/canvas-render.ts`'s `handOverCanvasStage`
103
+ * makes with the stage. Without it the strip printed a document that was not on screen: `⌘\` moved
104
+ * the tab into the side pane, the stage showed it, and the strip went on drawing the primary's tabs
105
+ * with the primary's chip marked active.
106
+ *
107
+ * The fallback is also what makes the primary work before `stampShellRegions()` has run — and in
108
+ * the tests, which mount an unstamped node.
109
+ */
110
+ function hostFor(pane: Pane): HTMLElement | null {
111
+ return resolveRegion(paneStripRegion(pane.id)) ?? _primaryHost;
112
+ }
25
113
 
26
114
  /**
27
- * Mount the tab strip into the given host element.
115
+ * Mount the tab strips. `host` is the PRIMARY pane's strip host.
28
116
  *
29
117
  * @param {HTMLElement} host
30
118
  */
31
119
  export function mount(host: HTMLElement) {
32
- _host = host;
33
- _lastActiveId = null;
120
+ _primaryHost = host;
121
+ _lastActive.clear();
122
+ _overflowing.clear();
123
+ _hosts.clear();
34
124
  _scope = effectScope();
35
125
  _scope.run(() => {
36
126
  effect(() => {
37
- void workspace.tabOrder;
38
- void workspace.activeTabId;
127
+ void workspace.activePaneId;
128
+ for (const pane of workspace.panes) {
129
+ void pane.id;
130
+ void pane.tabOrder;
131
+ void pane.activeTabId;
132
+ /* NO DERIVATION READS HERE, and the three that were here are the clearest example in the
133
+ package of a tracked input that tracks nothing. `render()` is called from inside this
134
+ effect, synchronously, so every value its templates read IS a dependency —
135
+ `derivationChipTpl` reads `derived.kind`, `derived.preset` and `derived.media` to build
136
+ the chip's label, and `renderPane` reads `pane.derived` to choose the branch. Three
137
+ `void` lines restating them could each be inverted with no test in the suite able to
138
+ tell, because the behaviour they claimed to buy was already bought one function down.
139
+ The loop reads below are a different case and stay: `render()` draws ONE pane per host,
140
+ so a fact about a pane it does not draw — or about a tab in another pane's strip — has
141
+ no reader inside this effect. */
142
+ }
39
143
  for (const tab of workspace.tabs.values()) {
40
144
  void tab.doc.dirty;
41
145
  void tab.documentPath;
146
+ void tab.pinned;
147
+ void tab.preview;
148
+ void tab.session.openedFrom;
149
+ // The draft pill is on the chip, so the chip has to repaint when the flag changes — reading
150
+ // The absent key tracks its ADDITION too, which is the transition that matters most.
151
+ void tab.doc.content?.frontmatter?.[DRAFT_FIELD];
42
152
  }
153
+ // An edit is a commitment: a preview tab with unsaved changes is no longer disposable, and
154
+ // The dirty flags this effect already tracks are exactly the signal.
155
+ promoteDirtyPreviewTabs();
43
156
  render();
44
157
  });
45
158
  });
46
159
  }
47
160
 
48
161
  export function unmount() {
162
+ dismissOverflowMenu();
163
+ dismissTabContextMenu();
49
164
  _scope?.stop();
50
165
  _scope = null;
51
- _host = null;
166
+ _primaryHost = null;
167
+ _dragging = null;
168
+ _lastActive.clear();
169
+ _overflowing.clear();
170
+ _hosts.clear();
52
171
  }
53
172
 
54
173
  function render() {
55
- if (!_host) {
56
- return;
174
+ /* One host draws ONE pane. While the shell has a single strip, both panes resolve to the same
175
+ div — and the one whose tabs are on screen is the focused one, so it wins the tie. Without
176
+ that rule the answer was render order, which is how the strip came to mark a tab active in a
177
+ pane the stage was not showing. When each pane has a host of its own there are no ties. */
178
+ const claims = new Map<HTMLElement, Pane>();
179
+ for (const pane of workspace.panes) {
180
+ const host = hostFor(pane);
181
+ if (host && (!claims.has(host) || isPaneFocused(pane.id))) {
182
+ claims.set(host, pane);
183
+ }
184
+ }
185
+ const drawn = new Set<string>();
186
+ for (const [host, pane] of claims) {
187
+ renderPane(pane, host);
188
+ _hosts.set(pane.id, host);
189
+ drawn.add(pane.id);
57
190
  }
191
+ // A pane that has gone away — or that has lost the shared host to the pane now focused — leaves
192
+ // Its last host behind. Blank it, unless someone else has just drawn there, so no strip outlives
193
+ // The pane it belongs to.
194
+ // Deleting the entry the loop is standing on is defined behaviour for a Map iterator.
195
+ for (const [paneId, host] of _hosts) {
196
+ if (drawn.has(paneId)) {
197
+ continue;
198
+ }
199
+ _hosts.delete(paneId);
200
+ _lastActive.delete(paneId);
201
+ _overflowing.delete(paneId);
202
+ if (!claims.has(host)) {
203
+ litRender(nothing, host);
204
+ }
205
+ }
206
+ }
207
+
208
+ /**
209
+ * What a lens pane's strip says: the projection, and the document it is a projection OF.
210
+ *
211
+ * Not a tab chip, and deliberately not close-able as a tab: a lens owns no document, so the ✕ here
212
+ * runs `pane.unsplit` — the lens's only exit, because Pin is refused for a projection of a document
213
+ * that already has a tab beside it (§14.1).
214
+ */
215
+ function derivationChipTpl(pane: Pane, derived: PaneDerivation): TemplateResult {
216
+ const registry = activeRegistry();
217
+ /* For a LENS the first read already hops to the source pane. For an unresolved COMPANION it
218
+ answers null — the pane owns no tab yet — and the chip would say "no document" about a
219
+ derivation that knows perfectly well what it is a projection OF. */
220
+ const of = tabOfPane(pane.id) ?? tabOfPane(derived.sourcePaneId);
221
+ /* THE CHIP FINISHES ITS OWN SENTENCE. Two of the presets are labelled with an unfinished phrase
222
+ — "Same page at", "Same page in" — because the interesting half is the value: a strip reading
223
+ "Same page in" over a French document says nothing the pane beside it did not already say. */
224
+ const label =
225
+ derived.kind === "lens" && derived.preset === "breakpoint"
226
+ ? `${PRESET_LABELS.breakpoint} ${derived.media ? mediaDisplayName(derived.media) : "Base"}`
227
+ : derived.kind === "companion" && derived.preset === "locale"
228
+ ? `${PRESET_LABELS.locale} ${derived.locale ? localeLabel(derived.locale) : "—"}`
229
+ : PRESET_LABELS[derived.preset];
230
+ return html`
231
+ <div
232
+ class=${classMap({ focused: isPaneFocused(pane.id), "tab-strip-row": true })}
233
+ @mousedown=${() => focusPane(pane.id)}
234
+ >
235
+ <div class="tab-strip">
236
+ <div class="tab-derivation" title=${`${label} · ${of?.documentPath ?? "no document"}`}>
237
+ <span class="tab-derivation-preset">${label}</span>
238
+ <span class="tab-derivation-of">${of ? tabLabel(of) : "no document"}</span>
239
+ </div>
240
+ </div>
241
+ <button
242
+ class="tab-strip-overflow"
243
+ title=${registry?.get("pane.unsplit")?.title ?? "Close Side Pane"}
244
+ @click=${() => {
245
+ void registry?.run("pane.unsplit");
246
+ }}
247
+ >
248
+ <span aria-hidden="true">✕</span>
249
+ </button>
250
+ </div>
251
+ `;
252
+ }
58
253
 
59
- if (workspace.tabOrder.length === 0) {
60
- _lastActiveId = null;
61
- litRender(nothing, _host);
254
+ function renderPane(pane: Pane, host: HTMLElement) {
255
+ /* A LENS pane has no tabs and must not have an empty strip: its `tabOrder` is `[]` BY DESIGN, so
256
+ the branch below would blank the one row that says what the pane is. It draws a derivation chip
257
+ instead — the preset, what it is a projection of, and the two exits. A COMPANION owns real tabs
258
+ and draws them, because that is what it is. */
259
+ const { derived } = pane;
260
+ /* ONE condition, not two. This read `derived.kind === "lens" || pane.tabOrder.length === 0`, and
261
+ the first disjunct could be inverted with nothing in the suite able to notice — invariant D2
262
+ says a lens owns no tab, `applyDerivation` hands back anything it finds, and the only writer
263
+ that could put one there (`moveTabToPane`, through `revealOpenTab`) is reached solely by
264
+ `openFileInPane`, which the follow calls for COMPANIONS. So the second disjunct is already
265
+ total over a lens and the first chose nothing. */
266
+ if (derived && pane.tabOrder.length === 0) {
267
+ /* A COMPANION whose rule has not resolved reaches this too, and it has to. Its `tabOrder` is
268
+ empty for a different reason than a lens's — the document it wants is not open YET, or the
269
+ selection has nothing under it — and the branch below drew `nothing`: a pane with no chip,
270
+ no ✕ and no way out, which `paneIsEmpty` will not collapse because the derivation counts as
271
+ a subject. The chip is the pane's name and its exit.
272
+ NO `_lastActive` / `_overflowing` RESET HERE, and both were, until each was shown to be a
273
+ write nothing can read. `_overflowing` is measured by {@link syncOverflow} at the end of
274
+ every ordinary render, inside the same synchronous call, so a stale `true` carried through a
275
+ derivation is corrected before any frame sees it. `_lastActive` cannot survive the round trip
276
+ either: a tab can only come back through `insertIntoPane` and then `activateTab`, which are
277
+ two reactive writes and therefore two renders, and the first of them lands here with
278
+ `tabOrder` full and `activeTabId` still `null` — which resets the field anyway. Both were
279
+ written as bookkeeping and both were unfalsifiable; the branch below owns the answer. */
280
+ litRender(derivationChipTpl(pane, derived), host);
281
+ return;
282
+ }
283
+ if (pane.tabOrder.length === 0) {
284
+ _lastActive.set(pane.id, null);
285
+ _overflowing.set(pane.id, false);
286
+ litRender(nothing, host);
62
287
  return;
63
288
  }
64
289
 
290
+ const labels = tabLabels(pane);
291
+ const focused = isPaneFocused(pane.id);
292
+
65
293
  litRender(
66
294
  html`
67
- <div class="tab-strip" @wheel=${onWheel}>
68
- ${repeat(
69
- workspace.tabOrder,
70
- (id) => id,
71
- (id) => {
72
- const tab = workspace.tabs.get(id);
73
- if (!tab) {
74
- return nothing;
75
- }
76
- const isActive = id === workspace.activeTabId;
77
- const isDirty = tab.doc.dirty;
78
- const label = tabLabel(tab);
79
- return html`
80
- <div
81
- class=${classMap({ active: isActive, "tab-strip-tab": true })}
82
- @click=${() => activateTab(id)}
83
- @auxclick=${(e: MouseEvent) => {
84
- if (e.button === 1) {
85
- e.preventDefault();
86
- void requestClose(id);
87
- }
88
- }}
89
- title=${tab.documentPath || gridTabLabel(tab.id) || "Untitled"}
295
+ <div
296
+ class=${classMap({ focused, "tab-strip-row": true })}
297
+ @mousedown=${() => focusPane(pane.id)}
298
+ >
299
+ <div class="tab-strip" @wheel=${onWheel}>
300
+ ${repeat(
301
+ pane.tabOrder,
302
+ (id) => id,
303
+ (id, index) => tabChip(pane, id, index, labels),
304
+ )}
305
+ </div>
306
+ ${
307
+ _overflowing.get(pane.id) === true
308
+ ? // ONE accessible name (§10): the glyph is hidden, so `title` is both the tooltip and
309
+ // The name — `title` + a matching `aria-label` would announce it twice.
310
+ html`<button
311
+ class="tab-strip-overflow"
312
+ title="Show hidden tabs"
313
+ @click=${(e: MouseEvent) => openOverflowMenu(e, pane, labels)}
90
314
  >
91
- <span class="tab-strip-label">${label}</span>
92
- ${isDirty ? html`<span class="tab-strip-dirty">●</span>` : nothing}
93
- <button
94
- class="tab-strip-close"
95
- @click=${(e: Event) => {
96
- e.stopPropagation();
97
- void requestClose(id);
98
- }}
99
- >
100
- ×
101
- </button>
102
- </div>
103
- `;
104
- },
105
- )}
315
+ <span aria-hidden="true">⌄</span>
316
+ </button>`
317
+ : nothing
318
+ }
106
319
  </div>
107
320
  `,
108
- _host,
321
+ host,
109
322
  );
110
323
 
111
- if (workspace.activeTabId !== _lastActiveId) {
112
- _lastActiveId = workspace.activeTabId;
113
- _host
114
- .querySelector(".tab-strip-tab.active")
115
- ?.scrollIntoView({ block: "nearest", inline: "nearest" });
324
+ if (pane.activeTabId !== _lastActive.get(pane.id)) {
325
+ _lastActive.set(pane.id, pane.activeTabId);
326
+ host.querySelector(".tab-strip-tab.active")?.scrollIntoView({
327
+ block: "nearest",
328
+ inline: "nearest",
329
+ });
330
+ }
331
+
332
+ syncOverflow(pane, host);
333
+ }
334
+
335
+ /**
336
+ * One chip.
337
+ *
338
+ * @param {Pane} pane
339
+ * @param {string} id
340
+ * @param {number} index — the chip's slot in the pane's order, which is the drop target index
341
+ * @param {Map<string, string>} labels
342
+ */
343
+ function tabChip(pane: Pane, id: string, index: number, labels: Map<string, string>) {
344
+ const tab = workspace.tabs.get(id);
345
+ if (!tab) {
346
+ return nothing;
347
+ }
348
+ const isActive = id === pane.activeTabId;
349
+ const label = labels.get(id) ?? "Untitled";
350
+ const origin = tab.session.openedFrom;
351
+ return html`
352
+ <div
353
+ class=${classMap({
354
+ active: isActive,
355
+ dragging: _dragging === id,
356
+ pinned: tab.pinned,
357
+ preview: tab.preview,
358
+ "tab-strip-tab": true,
359
+ })}
360
+ draggable="true"
361
+ @click=${() => activateTab(id)}
362
+ @dblclick=${() => promoteTab(id)}
363
+ @dragstart=${(e: DragEvent) => onDragStart(e, id)}
364
+ @dragend=${() => onDragEnd()}
365
+ @dragover=${(e: DragEvent) => e.preventDefault()}
366
+ @drop=${(e: DragEvent) => onDrop(e, pane, index)}
367
+ @auxclick=${(e: MouseEvent) => {
368
+ if (e.button === 1) {
369
+ e.preventDefault();
370
+ void requestClose(id);
371
+ }
372
+ }}
373
+ @contextmenu=${(e: MouseEvent) => openTabContextMenu(e, tab)}
374
+ title=${tabTooltip(tab)}
375
+ >
376
+ ${origin ? html`<span class="tab-strip-origin" aria-hidden="true">↳</span>` : nothing}
377
+ <span class="tab-strip-label">${label}</span>
378
+ ${
379
+ /* The draft pill (§7.6). On the CHIP, not only inside the entry editor: the mistake this
380
+ prevents — publishing something you believed was private — is made while glancing at a
381
+ row of tabs, and it is made about a document that may not even be the active one. */
382
+ entryDraftPill(tab)
383
+ }
384
+ ${tab.doc.dirty ? html`<span class="tab-strip-dirty">●</span>` : nothing}
385
+ <button
386
+ class="tab-strip-pin"
387
+ title=${tab.pinned ? "Unpin" : "Pin"}
388
+ @click=${(e: Event) => {
389
+ e.stopPropagation();
390
+ setTabPinned(id, !tab.pinned);
391
+ }}
392
+ >
393
+ <span aria-hidden="true">${tab.pinned ? "◉" : "◎"}</span>
394
+ </button>
395
+ <button
396
+ class="tab-strip-close"
397
+ title="Close"
398
+ @click=${(e: Event) => {
399
+ e.stopPropagation();
400
+ void requestClose(id);
401
+ }}
402
+ >
403
+ ×
404
+ </button>
405
+ </div>
406
+ `;
407
+ }
408
+
409
+ // ─── Drag reorder ─────────────────────────────────────────────────────────────
410
+
411
+ function onDragStart(e: DragEvent, id: string) {
412
+ _dragging = id;
413
+ e.dataTransfer?.setData("text/plain", id);
414
+ if (e.dataTransfer) {
415
+ e.dataTransfer.effectAllowed = "move";
416
+ }
417
+ // The ghost is chrome state, not model state, so no effect fires for it.
418
+ render();
419
+ }
420
+
421
+ function onDragEnd() {
422
+ _dragging = null;
423
+ render();
424
+ }
425
+
426
+ /**
427
+ * Drop onto the chip at `index`. The model clamps the destination into the region the dragged tab's
428
+ * pinned state allows, so this only has to say where the pointer was.
429
+ */
430
+ function onDrop(e: DragEvent, pane: Pane, index: number) {
431
+ e.preventDefault();
432
+ const id = _dragging ?? e.dataTransfer?.getData("text/plain") ?? null;
433
+ _dragging = null;
434
+ if (!id || !pane.tabOrder.includes(id)) {
435
+ return;
436
+ }
437
+ moveTab(id, index);
438
+ }
439
+
440
+ /**
441
+ * Re-measure a pane's strip and re-render once if the chevron's presence changed.
442
+ *
443
+ * Measurement can only happen after lit has written the DOM, so this runs at the tail of
444
+ * `renderPane()` and guards its own re-entry on the boolean actually flipping — the chevron cannot
445
+ * oscillate.
446
+ */
447
+ function syncOverflow(pane: Pane, host: HTMLElement) {
448
+ const strip = host.querySelector(".tab-strip") as HTMLElement | null;
449
+ if (!strip) {
450
+ return;
451
+ }
452
+ const overflowing = strip.scrollWidth > strip.clientWidth;
453
+ if (overflowing !== (_overflowing.get(pane.id) === true)) {
454
+ _overflowing.set(pane.id, overflowing);
455
+ renderPane(pane, host);
116
456
  }
117
457
  }
118
458
 
119
459
  /**
120
460
  * Scroll the strip horizontally from wheel motion so overflowed tabs stay reachable — a plain mouse
121
461
  * wheel only emits deltaY, which does nothing in an overflow-x container. The scrollbar is hidden
122
- * by design, so the wheel is the primary way to reach off-screen tabs.
462
+ * by design; the overflow chevron is the pointer-independent route to the same tabs.
123
463
  *
124
464
  * @param {WheelEvent} e
125
465
  */
@@ -139,19 +479,449 @@ function onWheel(e: WheelEvent) {
139
479
  el.scrollLeft += delta;
140
480
  }
141
481
 
482
+ // ─── Overflow menu ────────────────────────────────────────────────────────────
483
+
484
+ /**
485
+ * Tab ids whose chip is wholly or partly outside a pane strip's scroll viewport.
486
+ *
487
+ * @param {string} [paneId] — defaults to the focused pane
488
+ */
489
+ export function hiddenTabIds(paneId: string = workspace.activePaneId): string[] {
490
+ const pane = workspace.panes.find((candidate) => candidate.id === paneId);
491
+ // The host the pane actually DREW into, not the one it would resolve: a pane with no strip on
492
+ // Screen has no chips out of view, and measuring another pane's strip would invent some.
493
+ const host = _hosts.get(paneId);
494
+ const strip = host?.querySelector(".tab-strip") as HTMLElement | null;
495
+ if (!pane || !strip) {
496
+ return [];
497
+ }
498
+ const left = strip.scrollLeft;
499
+ const right = left + strip.clientWidth;
500
+ const hidden: string[] = [];
501
+ const chips = [...strip.querySelectorAll(".tab-strip-tab")] as HTMLElement[];
502
+ for (const [index, chip] of chips.entries()) {
503
+ const id = pane.tabOrder[index];
504
+ if (id && (chip.offsetLeft < left || chip.offsetLeft + chip.offsetWidth > right)) {
505
+ hidden.push(id);
506
+ }
507
+ }
508
+ return hidden;
509
+ }
510
+
511
+ export function dismissOverflowMenu() {
512
+ _overflowHandle?.dismiss();
513
+ _overflowHandle = null;
514
+ }
515
+
142
516
  /**
143
- * Derive a short label from the tab's documentPath (virtual grid tabs label from their id).
517
+ * List the off-screen tabs. Falls back to every tab when nothing measures as hidden, because an
518
+ * empty menu is a dead control and happy-dom (plus any zero-height layout) measures everything at
519
+ * 0.
520
+ *
521
+ * @param {MouseEvent} e
522
+ * @param {Pane} pane
523
+ * @param {Map<string, string>} labels
524
+ */
525
+ function openOverflowMenu(e: MouseEvent, pane: Pane, labels: Map<string, string>) {
526
+ e.stopPropagation();
527
+ dismissOverflowMenu();
528
+ const hidden = hiddenTabIds(pane.id);
529
+ const ids = hidden.length > 0 ? hidden : [...pane.tabOrder];
530
+ const anchor = rectOf(e.currentTarget as HTMLElement);
531
+ _overflowHandle = renderPopover(
532
+ html`<sp-popover
533
+ open
534
+ style="position:fixed;z-index:10000;right:${Math.max(
535
+ 4,
536
+ window.innerWidth - anchor.right,
537
+ )}px;top:${anchor.bottom}px"
538
+ >
539
+ <sp-menu>
540
+ ${ids.map(
541
+ (id) => html`<sp-menu-item
542
+ ?selected=${id === pane.activeTabId}
543
+ @click=${() => {
544
+ dismissOverflowMenu();
545
+ activateTab(id);
546
+ }}
547
+ >${labels.get(id) ?? "Untitled"}</sp-menu-item
548
+ >`,
549
+ )}
550
+ </sp-menu>
551
+ </sp-popover>`,
552
+ {
553
+ dismissOnOutsideClick: true,
554
+ onDismiss: () => {
555
+ _overflowHandle = null;
556
+ },
557
+ },
558
+ );
559
+ }
560
+
561
+ // ─── Context menu ─────────────────────────────────────────────────────────────
562
+
563
+ let _tabCtxHandle: { dismiss: () => void } | null = null;
564
+
565
+ /** Dismiss the tab context menu if open. */
566
+ export function dismissTabContextMenu() {
567
+ _tabCtxHandle?.dismiss();
568
+ _tabCtxHandle = null;
569
+ }
570
+
571
+ /** One row of the tab menu. Every field on it was read off a command record. */
572
+ interface TabMenuItem {
573
+ label: string;
574
+ action?: () => void;
575
+ disabled?: boolean;
576
+ /** The `requires` sentence, printed under a disabled row. */
577
+ reason?: string;
578
+ /** What is true NOW, for a row whose command names a state — see {@link statedState}. */
579
+ state?: string | undefined;
580
+ /** A group boundary in `forPlacement`'s ordering. */
581
+ dividerAbove?: boolean;
582
+ }
583
+
584
+ /**
585
+ * A tab addresses ONE document, and this is everything the chip can say about it — keyed by the
586
+ * ARGUMENT NAME that asks for it.
587
+ *
588
+ * The whole `context/tab` placement asks for exactly one argument today: `content.setDraft
589
+ * {draft}`. The other five records take none, because they read the ACTIVE document — which is why
590
+ * {@link openTabContextMenu} activates the chip before it builds a single row.
591
+ *
592
+ * A fact is stated ONLY when it is true of this tab, and that is what decides whether a command
593
+ * appears at all: `styles/main.css` is an entry of no collection, so it states no `draft`, so "Set
594
+ * Draft" is not offered on it. The condition is `collectionOfPath` — the same question
595
+ * `content.setDraft`'s own `enablement` asks — so the menu never invents a rule the command does
596
+ * not have, and never renders a row into a refusal.
597
+ *
598
+ * The VALUE is the state the row would reach, not the state the tab is in: a setter is named for
599
+ * where it lands (`content/draft-state.ts` says why), so the row offers the flip of what is true
600
+ * now and {@link statedState} reads the current state back out of it for the checkmark.
601
+ */
602
+ function tabRowFacts(tab: Tab): Record<string, unknown> {
603
+ const facts: Record<string, unknown> = {};
604
+ if (collectionOfPath(tab.documentPath)) {
605
+ facts.draft = !isDraftEntry(entryFields(tab));
606
+ }
607
+ return facts;
608
+ }
609
+
610
+ /**
611
+ * The state a row is IN, derived from the state it would reach — or `undefined` when the record
612
+ * says nothing about state.
613
+ *
614
+ * A menu is the one surface that is READ before it is used, so it is the one surface that can show
615
+ * a boolean instead of asking the author to remember it. That is an argument for the SETTER over
616
+ * the toggle, and it is why this is derived from the args rather than from a list of command ids:
617
+ * `content.setDraft {draft}` names the state it reaches, so the row can say which state the tab is
618
+ * in now and still land somewhere definite when clicked. `document.togglePinned` names no state —
619
+ * "Pin / Unpin Document" is a title that admits it does not know which it will do — so it renders
620
+ * as a plain row here. Its idempotent sibling `document.setPinned {pinned}` would state its own
621
+ * value, with no edit to this file, on the day its record declares `context/tab`; that declaration
622
+ * lives in `workspace/workspace.ts`, which is the only place it can be made.
623
+ */
624
+ function statedState(args: Record<string, unknown>): string | undefined {
625
+ const entries = Object.entries(args);
626
+ const only = entries.length === 1 ? entries[0] : undefined;
627
+ if (!only || typeof only[1] !== "boolean") {
628
+ return undefined;
629
+ }
630
+ // The fact is the state the row would REACH, so the state it is in now is the negation. Phrased
631
+ // As `Key: yes|no` because the key is whatever the record's schema calls it — "Draft: no" reads,
632
+ // Where a sentence built around an arbitrary property name does not.
633
+ const key = only[0].charAt(0).toUpperCase() + only[0].slice(1);
634
+ return `${key}: ${only[1] ? "no" : "yes"}`;
635
+ }
636
+
637
+ /**
638
+ * The declared `context/tab` commands this chip can offer.
639
+ *
640
+ * Everything a row prints comes off the record — its title, its position (`forPlacement` sorts by
641
+ * `group`, and a change of group draws the divider), whether it is enabled and the sentence saying
642
+ * why not. Nothing here names a command, so a new `context/tab` record appears in the strip with no
643
+ * edit to this file — and with no registry published there are no rows at all, because every row
644
+ * there has ever been came from one.
645
+ */
646
+ function placedTabItems(tab: Tab): TabMenuItem[] {
647
+ const registry = activeRegistry();
648
+ if (!registry) {
649
+ return [];
650
+ }
651
+ const facts = tabRowFacts(tab);
652
+ const items: TabMenuItem[] = [];
653
+ let group: string | undefined;
654
+ for (const command of registry.forPlacement("context/tab")) {
655
+ const schema = command.args as
656
+ | { properties?: Record<string, unknown>; required?: readonly string[] }
657
+ | undefined;
658
+ if (!(schema?.required ?? []).every((key) => key in facts)) {
659
+ continue;
660
+ }
661
+ const args: Record<string, unknown> = {};
662
+ for (const key of Object.keys(schema?.properties ?? {})) {
663
+ if (key in facts) {
664
+ args[key] = facts[key];
665
+ }
666
+ }
667
+ const dividerAbove = items.length > 0 && command.group !== group;
668
+ ({ group } = command);
669
+ const reason = registry.disabledReason(command.id);
670
+ items.push({
671
+ state: statedState(args),
672
+ dividerAbove,
673
+ label: command.title,
674
+ ...(reason === undefined
675
+ ? {
676
+ action: () => {
677
+ void registry.run(command.id, args);
678
+ },
679
+ }
680
+ : { disabled: true, reason }),
681
+ });
682
+ }
683
+ return items;
684
+ }
685
+
686
+ /**
687
+ * Open the menu on a chip.
688
+ *
689
+ * **Activating first is the wiring, not a courtesy.** Five of the six `context/tab` records read
690
+ * `workspace.activeTabId` and the sixth reads `activeTab.value`, and the registry rebuilds its
691
+ * context on every `forPlacement` / `disabledReason` / `run` call. Build the rows without
692
+ * activating and the menu states the OTHER tab's enablement — "Keep Document Open" greyed out over
693
+ * a preview tab — and then acts on the other tab too. Activation makes the tab the author aimed at
694
+ * the one the records are talking about, which is what every list does on right-click anyway.
695
+ *
696
+ * With nothing declared, no menu opens: an empty popover is a dead control, the same judgement the
697
+ * overflow chevron makes one section up.
698
+ */
699
+ function openTabContextMenu(e: MouseEvent, tab: Tab) {
700
+ e.preventDefault();
701
+ e.stopPropagation();
702
+ dismissTabContextMenu();
703
+ dismissOverflowMenu();
704
+ activateTab(tab.id);
705
+
706
+ const items = placedTabItems(tab);
707
+ if (items.length === 0) {
708
+ return;
709
+ }
710
+
711
+ // Clamp to the viewport: a right-click near an edge would otherwise open a menu partly off
712
+ // Screen, and a menu you cannot read is a menu you cannot use.
713
+ const x = Math.min(e.clientX, window.innerWidth - 4);
714
+ const y = Math.min(e.clientY, window.innerHeight - 4);
715
+
716
+ _tabCtxHandle = renderPopover(
717
+ html`<sp-popover open style="position:fixed;z-index:10000;left:${x}px;top:${y}px">
718
+ <sp-menu>${items.map((item) => tabMenuItemTemplate(item))}</sp-menu>
719
+ </sp-popover>`,
720
+ {
721
+ dismissOnOutsideClick: true,
722
+ onDismiss: () => {
723
+ _tabCtxHandle = null;
724
+ },
725
+ },
726
+ );
727
+ }
728
+
729
+ /**
730
+ * The line under a row: why it is disabled, or what is true now.
731
+ *
732
+ * A disabled row prints the record's own `requires` sentence — the same words the palette and the
733
+ * assistant print, never re-worded here. An enabled row whose command names a state prints that
734
+ * state, because a setter is named for where it LANDS and its title alone cannot say where you are
735
+ * now.
736
+ *
737
+ * A description rather than a checkbox, for a mechanical reason: Spectrum's `Menu` reassigns every
738
+ * item's role one frame after connect whenever the menu declares no `selects`, so
739
+ * `role="menuitemcheckbox"` does not survive — verified in a real browser, because happy-dom never
740
+ * runs that reassignment and no test here can see it either way. Declaring `selects` would make all
741
+ * six rows checkboxes, including the five that carry no state.
742
+ *
743
+ * @param {TabMenuItem} item
744
+ */
745
+ function descriptionTemplate(item: TabMenuItem) {
746
+ const line = item.reason === undefined ? item.state : `Needs ${item.reason}`;
747
+ return line === undefined ? nothing : html`<span slot="description">${line}</span>`;
748
+ }
749
+
750
+ /** One rendered row. A disabled row stays on screen when clicked — it is there to be read. */
751
+ function tabMenuItemTemplate(item: TabMenuItem) {
752
+ return html`${
753
+ item.dividerAbove ? html`<sp-menu-divider role="separator"></sp-menu-divider>` : nothing
754
+ }<sp-menu-item
755
+ ?disabled=${item.disabled === true}
756
+ aria-disabled=${item.disabled === true ? "true" : "false"}
757
+ @click=${() => {
758
+ if (item.disabled === true) {
759
+ return;
760
+ }
761
+ dismissTabContextMenu();
762
+ void item.action?.();
763
+ }}
764
+ >${item.label}${descriptionTemplate(item)}</sp-menu-item
765
+ >`;
766
+ }
767
+
768
+ // ─── Labels ───────────────────────────────────────────────────────────────────
769
+
770
+ /** One tab as the labeller sees it. */
771
+ export interface LabelInput {
772
+ id: string;
773
+ documentPath: string | null;
774
+ /** What to show when there is no path at all (a virtual grid tab, an untitled document). */
775
+ fallback: string;
776
+ }
777
+
778
+ /**
779
+ * A page's route, or null when the path is not a page.
780
+ *
781
+ * `pages/blog/[slug].md` → `/blog/[slug]`, `pages/about/index.md` → `/about`, `pages/index.md` →
782
+ * `/`. The route is what the author named the thing; the file name is an implementation detail of
783
+ * the router, and it is the same word (`index`) for every section of the site.
784
+ *
785
+ * @param {string} path
786
+ * @returns {string | null}
787
+ */
788
+ export function pageRoute(path: string): string | null {
789
+ const normalized = path.replace(/^\.\//, "");
790
+ if (!normalized.startsWith("pages/")) {
791
+ return null;
792
+ }
793
+ const withoutExt = normalized.slice("pages/".length).replace(/\.[^./]+$/, "");
794
+ const segments = withoutExt.split("/").filter((segment) => segment !== "");
795
+ if (segments.at(-1) === "index") {
796
+ segments.pop();
797
+ }
798
+ return `/${segments.join("/")}`;
799
+ }
800
+
801
+ /**
802
+ * Give every tab the shortest path suffix that tells it apart from the others.
803
+ *
804
+ * Pages label by route and are exempt from the widening loop — two distinct files cannot produce
805
+ * one route. Everything else starts at its basename and grows one leading segment at a time, but
806
+ * only within the group that actually collides, so one pair of `index.json` files does not push a
807
+ * path onto every other tab in the strip.
808
+ *
809
+ * @param {LabelInput[]} inputs
810
+ * @returns {Map<string, string>} Tab id → label
811
+ */
812
+ export function computeTabLabels(inputs: LabelInput[]): Map<string, string> {
813
+ interface Entry {
814
+ id: string;
815
+ parts: string[];
816
+ depth: number;
817
+ label: string;
818
+ fixed: boolean;
819
+ }
820
+ const entries: Entry[] = inputs.map((input) => {
821
+ const path = input.documentPath;
822
+ if (!path) {
823
+ return { depth: 0, fixed: true, id: input.id, label: input.fallback, parts: [] };
824
+ }
825
+ const route = pageRoute(path);
826
+ if (route !== null) {
827
+ return { depth: 0, fixed: true, id: input.id, label: route, parts: [] };
828
+ }
829
+ const parts = path.split("/").filter((segment) => segment !== "" && segment !== ".");
830
+ return {
831
+ depth: 1,
832
+ fixed: false,
833
+ id: input.id,
834
+ label: parts.slice(-1).join("/") || path,
835
+ parts,
836
+ };
837
+ });
838
+
839
+ // Each pass widens exactly the entries that still collide. It terminates because every pass
840
+ // Either widens at least one entry (bounded by its segment count) or changes nothing.
841
+ for (;;) {
842
+ const groups = new Map<string, Entry[]>();
843
+ for (const entry of entries) {
844
+ const group = groups.get(entry.label);
845
+ if (group) {
846
+ group.push(entry);
847
+ } else {
848
+ groups.set(entry.label, [entry]);
849
+ }
850
+ }
851
+ let widened = false;
852
+ for (const group of groups.values()) {
853
+ if (group.length < 2) {
854
+ continue;
855
+ }
856
+ for (const entry of group) {
857
+ if (entry.fixed || entry.depth >= entry.parts.length) {
858
+ continue;
859
+ }
860
+ entry.depth += 1;
861
+ entry.label = entry.parts.slice(-entry.depth).join("/");
862
+ widened = true;
863
+ }
864
+ }
865
+ if (!widened) {
866
+ break;
867
+ }
868
+ }
869
+
870
+ return new Map(entries.map((entry) => [entry.id, entry.label]));
871
+ }
872
+
873
+ /**
874
+ * Labels for the tabs in one pane, keyed by tab id.
875
+ *
876
+ * Disambiguation is per-pane on purpose: the strip is what has to be readable, and widening a label
877
+ * because a tab in the OTHER pane collides with it would make one strip harder to read to solve a
878
+ * problem nobody can see.
879
+ *
880
+ * @param {Pane} pane
881
+ */
882
+ function tabLabels(pane: Pane): Map<string, string> {
883
+ return computeTabLabels(
884
+ pane.tabOrder.flatMap((id) => {
885
+ const tab = workspace.tabs.get(id);
886
+ return tab
887
+ ? [
888
+ {
889
+ documentPath: tab.documentPath,
890
+ fallback: gridTabLabel(tab.id) ?? "Untitled",
891
+ id,
892
+ },
893
+ ]
894
+ : [];
895
+ }),
896
+ );
897
+ }
898
+
899
+ /**
900
+ * The hover text: the full path, plus the document this tab was drilled in from.
901
+ *
902
+ * @param {Tab} tab
903
+ * @returns {string}
904
+ */
905
+ function tabTooltip(tab: Tab): string {
906
+ const base = tab.documentPath || gridTabLabel(tab.id) || "Untitled";
907
+ const origin = tab.session.openedFrom;
908
+ const withOrigin = origin?.documentPath ? `${base}\nOpened from ${origin.documentPath}` : base;
909
+ return tab.preview ? `${withOrigin}\nPreview — double-click to keep open` : withOrigin;
910
+ }
911
+
912
+ /**
913
+ * The label a single tab shows. Exported for the close-confirmation copy, which names one tab with
914
+ * no strip to disambiguate it against.
144
915
  *
145
916
  * @param {Tab} tab
146
917
  * @returns {string}
147
918
  */
148
- function tabLabel(tab: Tab) {
919
+ export function tabLabel(tab: Tab): string {
149
920
  const path = tab.documentPath;
150
921
  if (!path) {
151
922
  return gridTabLabel(tab.id) ?? "Untitled";
152
923
  }
153
- const parts = path.split("/");
154
- return parts.at(-1);
924
+ return pageRoute(path) ?? path.split("/").at(-1) ?? path;
155
925
  }
156
926
 
157
927
  /**
@@ -160,39 +930,249 @@ function tabLabel(tab: Tab) {
160
930
  * (no other peer is focused on its path). When peers remain, the shared session lives on and the
161
931
  * edits are still on the server, so closing is safe.
162
932
  *
933
+ * **`dirty` is not the whole question, because a Monaco buffer can hold work the document has never
934
+ * received.** `requestClose` flushes the armed commit before asking, so ordinary typing IS dirty by
935
+ * the time we get here — but a commit is allowed to fail to land (unparseable source keeps the
936
+ * buffer rather than resyncing over a half-typed heading), and that text exists nowhere else.
937
+ * {@link tabBufferUnsaved} is that residue, and it is the user's own typing by construction.
938
+ *
163
939
  * @param {Tab} tab
164
940
  */
165
941
  export function shouldWarnOnClose(tab: Tab): boolean {
166
- if (!tab.doc.dirty) {
942
+ const residue = tabBufferUnsaved(tab);
943
+ if (!tab.doc.dirty && !residue) {
167
944
  return false;
168
945
  }
169
946
  const state = collabState(tab);
170
947
  if (!state.active) {
171
948
  return true;
172
949
  }
950
+ // "Peers remain, so the edits are still on the server" holds only for a client whose edits REACH
951
+ // The server. A read-only client publishes nothing (`collabReadOnly`), so its work is in this
952
+ // Browser and nowhere else, and the room being busy is no comfort at all.
953
+ if (collabReadOnly(tab)) {
954
+ return true;
955
+ }
956
+ /* AND THE FREEZE IS THE SECOND WAY EDITS DO NOT REACH THE SERVER. Buffer residue is by
957
+ construction text `onTransact` never saw — `commitTabBuffers` ran above this gate and the write
958
+ was refused, so nothing was ever published, mirrored or written to disk. It is in this browser
959
+ and nowhere else for EVERY client, not merely for a read-only one, so no peer count can make it
960
+ safe. Worse, the two predicates are the same predicate under the source-canonical freeze: the
961
+ lock holder is by definition a peer focused on this path, so the busier the room got, the
962
+ quieter the close became. Asked before the peer count rather than after, because the peer count
963
+ has no jurisdiction over text the room has never been told about. */
964
+ if (residue) {
965
+ return true;
966
+ }
173
967
  const peersHere = state.peers.filter((p) => p.state?.focusedPath === tab.documentPath);
174
968
  return peersHere.length === 0;
175
969
  }
176
970
 
177
971
  /**
178
- * Close a tab, prompting if closing would lose unsaved work.
972
+ * Why a save is not one of the ways out of this tab, or `null` when it is.
973
+ *
974
+ * §14.7: a dialog may not offer an answer the app cannot honour. Two states make "Save" a button
975
+ * that would run and still not write what is on screen, and they are different sentences because
976
+ * they are different situations to be in.
977
+ *
978
+ * **The buffer case is the one that was being got wrong.** `requestClose` commits every buffer
979
+ * before it asks anything, so by the time this runs a `typed()` buffer means the commit could not
980
+ * land: unparseable source deliberately keeps the buffer rather than resyncing over a half-typed
981
+ * heading, the collab freeze refuses the dock's body write outright, and a commit that threw is
982
+ * reported and stepped over. In every one of those the document does not contain the text the
983
+ * author is looking at — so `saveFile` would write the file WITHOUT it, stamp "Saved just now", and
984
+ * close the tab. The prompt appeared, the author chose the careful answer, and the work went
985
+ * anyway.
986
+ */
987
+ function saveUnavailableReason(tab: Tab): string | null {
988
+ if (collabReadOnly(tab)) {
989
+ return (
990
+ `You have read access to this session, so "${tabLabel(tab)}" has changes that were never ` +
991
+ `published and have nowhere to be saved. Closing discards them.`
992
+ );
993
+ }
994
+ if (tabBufferUnsaved(tab)) {
995
+ return (
996
+ `"${tabLabel(tab)}" has text in its editor that the document could not be given — the ` +
997
+ `source does not parse, or a collaborator holds the source lock. Saving would write the ` +
998
+ `file without it, so it is not offered. Closing discards that text.`
999
+ );
1000
+ }
1001
+ return null;
1002
+ }
1003
+
1004
+ /**
1005
+ * Ask what to do about a tab that is about to close over unsaved work. True means go ahead.
1006
+ *
1007
+ * **Two dialogs, because there are two situations and only one of them has three answers.** Save /
1008
+ * Discard / Cancel is right when a save would do something. When it would not — see
1009
+ * {@link saveUnavailableReason} — offering the button anyway would put a control that cannot work
1010
+ * on the one dialog whose entire job is to be trusted about unsaved work. Those cases get the
1011
+ * honest pair: discard, or keep editing.
1012
+ */
1013
+ async function confirmClose(tab: Tab): Promise<boolean> {
1014
+ const cannotSave = saveUnavailableReason(tab);
1015
+ if (cannotSave) {
1016
+ return showConfirmDialog("Changes Cannot Be Saved", cannotSave, {
1017
+ cancelLabel: "Keep Editing",
1018
+ confirmLabel: "Close Without Saving",
1019
+ destructive: true,
1020
+ });
1021
+ }
1022
+ const answer = await showSaveDiscardDialog(
1023
+ "Unsaved Changes",
1024
+ `"${tabLabel(tab)}" has unsaved changes.`,
1025
+ { discardLabel: "Close Without Saving" },
1026
+ );
1027
+ if (answer === "cancel") {
1028
+ return false;
1029
+ }
1030
+ // A failed save must not close the tab: treating "Save" as "save and then close regardless" turns
1031
+ // A write error the user just watched into the loss the prompt existed to prevent.
1032
+ return answer === "discard" || (await saveFile(tab));
1033
+ }
1034
+
1035
+ /**
1036
+ * Close a tab, offering to save first when closing would lose unsaved work.
1037
+ *
1038
+ * **Three ways out, not two.** This asked "Close without saving?" over a two-way confirm, so the
1039
+ * only buttons were the one that threw the work away and the one that did nothing — a dialog that
1040
+ * cannot do the thing the user most likely wants. §8.7's table assigns "Unsaved-work decisions" to
1041
+ * `showSaveDiscardDialog`, and this is that decision — except when a save is not one of the ways
1042
+ * out at all, which is {@link confirmClose}'s job to tell apart.
1043
+ *
1044
+ * **A failed save must not close the tab.** `saveFile` reports its own failures and returns whether
1045
+ * the bytes landed; treating "Save" as "save and then close regardless" would turn a write error
1046
+ * the user just watched into the loss the prompt existed to prevent. It stays open, still dirty,
1047
+ * with the error in Problems.
1048
+ *
1049
+ * The tab is passed to `saveFile` explicitly: a × is clicked on tabs that are not focused, and the
1050
+ * default target is the focused one.
1051
+ *
1052
+ * Exported because ⌘W is the same action. `editor/shortcuts.ts` used to re-implement it — down to
1053
+ * copying the wording out of this file — which is exactly how the two drifted apart the last time.
179
1054
  *
180
1055
  * @param {string} id
181
1056
  */
182
- async function requestClose(id: string) {
1057
+ export async function requestClose(id: string) {
183
1058
  const tab = workspace.tabs.get(id);
184
1059
  if (!tab) {
185
1060
  return;
186
1061
  }
187
- if (shouldWarnOnClose(tab)) {
188
- const confirmed = await showConfirmDialog(
189
- "Unsaved Changes",
190
- `"${tabLabel(tab)}" has unsaved changes. Close without saving?`,
191
- { confirmLabel: "Close", destructive: true },
1062
+ /* THE CLOSE IS A WRITE BEFORE IT IS A QUESTION.
1063
+ Both unsaved-work gates read `tab.doc.dirty`, and a Monaco buffer's armed commit has not
1064
+ dirtied anything yet — so typing the last character of a handler and pressing ⌘W closed the tab
1065
+ with no prompt and took the last 500ms (dock) / 600ms (source) of typing with it. A disposer
1066
+ cannot cover this: `closeTab` below deletes the tab first, and every commit checks `tabIsLive`
1067
+ precisely so it will not write into a tab nobody can read.
1068
+ The source view's commit parses through the format host before it assigns, and the next line
1069
+ reads the result, so this is awaited. */
1070
+ await commitTabBuffers(tab);
1071
+ /* AND THE TAB MAY BE GONE, because that await is a window and everything below acts on `tab`.
1072
+ `closeAllTabs` (project switch, project close) and the preview slot's replacement both destroy
1073
+ a tab synchronously from an event this close is not ordered against, so the flush's own
1074
+ duration is enough. Everything below is then addressed to a tab nobody can see: the freeze is a
1075
+ prompt about a document that is no longer on screen, and `saveFile(tab)` on its Save button is
1076
+ a WRITE — of `tab.documentPath`, which is project-relative, through a `platform.projectRoot`
1077
+ that the project switch has already moved. The old project's document, written into the new
1078
+ project, at the same relative path.
1079
+ Re-read by id rather than trusting the captured object: identity survives destruction, and
1080
+ membership of `workspace.tabs` is the fact `tabIsLive` is defined as. */
1081
+ if (!workspace.tabs.has(id)) {
1082
+ return;
1083
+ }
1084
+ if (shouldWarnOnClose(tab) && !(await confirmClose(tab))) {
1085
+ return;
1086
+ }
1087
+ closeTab(id);
1088
+ }
1089
+
1090
+ /**
1091
+ * Ask about EVERY open tab at once, before something destroys them all. True means go ahead.
1092
+ *
1093
+ * `workspace.closeAllTabs()` disposes the lot with no gate of any kind, and one caller reaches it
1094
+ * from a gesture that loses work: activating another project. Every dirty document went, silently,
1095
+ * with no dialog anywhere on the path. It was the last unguarded destroyer in the matrix — ⌘W, the
1096
+ * ×, quitting and the preview slot's replacement each acquired one, and this one predates them
1097
+ * all.
1098
+ *
1099
+ * **One prompt, not N.** A project switch is a single decision; walking the author through six
1100
+ * dialogs to make it is how a prompt becomes something to click past. The count is in the sentence
1101
+ * instead, because "3 documents have unsaved changes" is the fact that decides the answer.
1102
+ *
1103
+ * **The same three rules as {@link confirmClose}, applied to the set.** {@link shouldWarnOnClose}
1104
+ * is the one definition of "closing this loses work" — it already knows about collab peers who
1105
+ * still hold the room, read-only sessions and buffers the document never received — so the set is
1106
+ * whatever it says yes to.
1107
+ *
1108
+ * **§14.7 says a dialog may not offer an answer the app cannot honour; it does not say to withdraw
1109
+ * one it can.** The blocked check used to be all-or-nothing, so an author with five dirty documents
1110
+ * and one unparseable source buffer was offered "Close Without Saving" or "Keep Editing" — and the
1111
+ * forward answer threw away four documents that would have written perfectly. The rule the button
1112
+ * has to satisfy is that its LABEL is true: "Save All" is a lie when one of them cannot be saved,
1113
+ * and `Save 4 of 5` is not. So the split is named in the sentence, the count is on the button, and
1114
+ * the only prompt that drops to the honest pair is the one where nothing at all can be written.
1115
+ *
1116
+ * **A failed save cancels the switch.** Same reason as the single-tab close: an author who watched
1117
+ * a write fail must not then watch the document be discarded because they had asked to save it.
1118
+ *
1119
+ * @param {string} action What the confirmation is for, e.g. "Opening another project".
1120
+ * @returns {Promise<boolean>} True to proceed with the destruction.
1121
+ */
1122
+ export async function confirmCloseAll(action: string): Promise<boolean> {
1123
+ // The close is a write before it is a question, for a set exactly as for one tab. At most two
1124
+ // Monaco buffers are mounted app-wide, so this is a no-op for every tab but theirs. Snapshotted,
1125
+ // Because each commit is an await and the map is live.
1126
+ const open = [...workspace.tabs.values()];
1127
+ for (const tab of open) {
1128
+ await commitTabBuffers(tab);
1129
+ }
1130
+ const unsaved = [...workspace.tabs.values()].filter((tab) => shouldWarnOnClose(tab));
1131
+ if (unsaved.length === 0) {
1132
+ return true;
1133
+ }
1134
+ const count =
1135
+ unsaved.length === 1
1136
+ ? `"${tabLabel(unsaved[0]!)}" has unsaved changes`
1137
+ : `${unsaved.length} documents have unsaved changes`;
1138
+ const blocked = unsaved.filter((tab) => saveUnavailableReason(tab) !== null);
1139
+ const saveable = unsaved.filter((tab) => saveUnavailableReason(tab) === null);
1140
+ const blockedNames =
1141
+ blocked.length === 1 ? `"${tabLabel(blocked[0]!)}"` : `${blocked.length} of them`;
1142
+ if (saveable.length === 0) {
1143
+ // Nothing to offer: every dirty document in the set would be written without the text the
1144
+ // Author is looking at, so there is no honourable Save and the pair is the whole truth.
1145
+ return showConfirmDialog(
1146
+ "Changes Cannot Be Saved",
1147
+ `${action} closes every open document, and ${count}. ${blockedNames} cannot be ` +
1148
+ `saved at all, so saving would leave work behind. Closing discards it.`,
1149
+ { cancelLabel: "Keep Editing", confirmLabel: "Close Without Saving", destructive: true },
192
1150
  );
193
- if (!confirmed) {
194
- return;
1151
+ }
1152
+ const answer = await showSaveDiscardDialog(
1153
+ "Unsaved Changes",
1154
+ blocked.length === 0
1155
+ ? `${action} closes every open document, and ${count}.`
1156
+ : `${action} closes every open document, and ${count}. ${blockedNames} cannot be saved at ` +
1157
+ `all — saving writes the other ${saveable.length} and discards ` +
1158
+ `${blocked.length === 1 ? "that one" : "those"}.`,
1159
+ {
1160
+ discardLabel: "Close Without Saving",
1161
+ saveLabel: blocked.length === 0 ? "Save All" : `Save ${saveable.length} of ${unsaved.length}`,
1162
+ },
1163
+ );
1164
+ if (answer === "cancel") {
1165
+ return false;
1166
+ }
1167
+ if (answer === "discard") {
1168
+ return true;
1169
+ }
1170
+ // The blocked ones are deliberately not attempted: `saveFile` would report success for a write
1171
+ // That left the buffer's text behind, which is the whole reason they were named on the button.
1172
+ for (const tab of saveable) {
1173
+ if (!(await saveFile(tab))) {
1174
+ return false;
195
1175
  }
196
1176
  }
197
- closeTab(id);
1177
+ return true;
198
1178
  }