@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.
- package/canvas.html +21 -0
- package/dist/chunks/Overlay.dev-qfjzkms8.js +23 -0
- package/dist/chunks/{Overlay.dev-cszx7yn5.js.map → Overlay.dev-qfjzkms8.js.map} +1 -1
- package/dist/chunks/{adapter-z8fkmbmv.js → adapter-qfnesn5v.js} +2 -2
- package/dist/chunks/{cf-settings-mmyv436d.js → cf-settings-73sh5z4p.js} +3 -3
- package/dist/chunks/{components-799jpvhm.js → components-2bpqp4mq.js} +5 -7
- package/dist/chunks/{components-799jpvhm.js.map → components-2bpqp4mq.js.map} +1 -1
- package/dist/chunks/content-source-34gnkbnn.js +38 -0
- package/dist/chunks/content-source-34gnkbnn.js.map +9 -0
- package/dist/chunks/convert-to-repeater-t8kxre5s.js +258 -0
- package/dist/chunks/convert-to-repeater-t8kxre5s.js.map +10 -0
- package/dist/chunks/extension-sections-6mqydz35.js +301 -0
- package/dist/chunks/extension-sections-6mqydz35.js.map +11 -0
- package/dist/chunks/files-8kn321h1.js +70 -0
- package/dist/chunks/files-8kn321h1.js.map +9 -0
- package/dist/chunks/{format-host-8jxwts2p.js → format-host-mkwsmt8d.js} +3 -3
- package/dist/chunks/git-panel-tjbr191d.js +51 -0
- package/dist/chunks/git-panel-tjbr191d.js.map +9 -0
- package/dist/chunks/{index-baeg2x3y.js → index-7pxchcce.js} +4 -3
- package/dist/chunks/{index-baeg2x3y.js.map → index-7pxchcce.js.map} +3 -3
- package/dist/chunks/{jx-validate-qx8degj7.js → jx-validate-v6qgknrs.js} +3 -3
- package/dist/chunks/layers-panel-87pb25km.js +58 -0
- package/dist/chunks/layers-panel-87pb25km.js.map +9 -0
- package/dist/chunks/{monaco-setup-zjdp9zdw.js → monaco-setup-e0yh25j4.js} +4 -75
- package/dist/chunks/{monaco-setup-zjdp9zdw.js.map → monaco-setup-e0yh25j4.js.map} +3 -4
- package/dist/chunks/publish-panel-m0fcgdrt.js +21 -0
- package/dist/chunks/publish-panel-m0fcgdrt.js.map +9 -0
- package/dist/chunks/quick-search-rhs6cbsb.js +53 -0
- package/dist/chunks/quick-search-rhs6cbsb.js.map +9 -0
- package/dist/chunks/right-panel-zpdxd98t.js +37 -0
- package/dist/chunks/right-panel-zpdxd98t.js.map +9 -0
- package/dist/chunks/section-registry-zmckaxf4.js +30 -0
- package/dist/chunks/section-registry-zmckaxf4.js.map +9 -0
- package/dist/chunks/sp-overlay.dev-9ran0sfq.js +18 -0
- package/dist/chunks/sp-overlay.dev-9ran0sfq.js.map +9 -0
- package/dist/chunks/sp-popover.dev-rebmdncr.js +9 -0
- package/dist/chunks/sp-popover.dev-rebmdncr.js.map +9 -0
- package/dist/chunks/sp-progress-circle.dev-5vkppfkt.js +15 -0
- package/dist/chunks/sp-progress-circle.dev-5vkppfkt.js.map +9 -0
- package/dist/chunks/sp-tooltip.dev-ykqrg8h5.js +15 -0
- package/dist/chunks/sp-tooltip.dev-ykqrg8h5.js.map +9 -0
- package/dist/chunks/{sp-tray.dev-pb2fsd65.js → sp-tray.dev-ey59vfh7.js} +10 -8
- package/dist/chunks/{sp-tray.dev-pb2fsd65.js.map → sp-tray.dev-ey59vfh7.js.map} +2 -2
- package/dist/chunks/{studio-9qr6fq7p.js → studio-0rqqhrdy.js} +10 -378
- package/dist/chunks/{studio-9qr6fq7p.js.map → studio-0rqqhrdy.js.map} +4 -14
- package/dist/chunks/{studio-5x6tqqec.js → studio-2sj1jj3v.js} +10 -8
- package/dist/chunks/{studio-5x6tqqec.js.map → studio-2sj1jj3v.js.map} +2 -2
- package/dist/chunks/{studio-h3fw0wn8.js → studio-3fk1ac9z.js} +6 -4
- package/dist/chunks/{studio-h3fw0wn8.js.map → studio-3fk1ac9z.js.map} +2 -2
- package/dist/chunks/studio-41exgzhq.js +6690 -0
- package/dist/chunks/studio-41exgzhq.js.map +38 -0
- package/dist/chunks/{studio-7v2bpweq.js → studio-4mqb9a0j.js} +6 -4
- package/dist/chunks/{studio-7v2bpweq.js.map → studio-4mqb9a0j.js.map} +2 -2
- package/dist/chunks/studio-50k77cca.js +1787 -0
- package/dist/chunks/studio-50k77cca.js.map +18 -0
- package/dist/chunks/{studio-56v3zwk6.js → studio-6a0h7sne.js} +10 -8
- package/dist/chunks/{studio-56v3zwk6.js.map → studio-6a0h7sne.js.map} +2 -2
- package/dist/chunks/studio-6f0am8f2.js +53 -0
- package/dist/chunks/studio-6f0am8f2.js.map +10 -0
- package/dist/chunks/studio-6nmjc5qk.js +7002 -0
- package/dist/chunks/studio-6nmjc5qk.js.map +32 -0
- package/dist/chunks/studio-7afd21nw.js +67 -0
- package/dist/chunks/studio-7afd21nw.js.map +10 -0
- package/dist/chunks/{studio-xze4zrs1.js → studio-7bmdvzne.js} +2 -2
- package/dist/chunks/studio-7bmdvzne.js.map +10 -0
- package/dist/chunks/studio-8jpzqk5s.js +128 -0
- package/dist/chunks/studio-8jpzqk5s.js.map +10 -0
- package/dist/chunks/studio-94m77tgk.js +1482 -0
- package/dist/chunks/studio-94m77tgk.js.map +13 -0
- package/dist/chunks/studio-9je6w5sq.js +1000 -0
- package/dist/chunks/studio-9je6w5sq.js.map +16 -0
- package/dist/chunks/{studio-w7c6kvk7.js → studio-a7pzveq1.js} +2 -2
- package/dist/chunks/{studio-gs6kqdt1.js → studio-adn0z1k3.js} +14 -8
- package/dist/chunks/{studio-gs6kqdt1.js.map → studio-adn0z1k3.js.map} +3 -3
- package/dist/chunks/{studio-kr88ryr7.js → studio-bsxxx3x2.js} +17 -15
- package/dist/chunks/{studio-kr88ryr7.js.map → studio-bsxxx3x2.js.map} +7 -6
- package/dist/chunks/studio-bw5dz6km.js +608 -0
- package/dist/chunks/studio-bw5dz6km.js.map +21 -0
- package/dist/chunks/studio-c6s5getc.js +4232 -0
- package/dist/chunks/studio-c6s5getc.js.map +39 -0
- package/dist/chunks/studio-cjm7fdjf.js +451 -0
- package/dist/chunks/studio-cjm7fdjf.js.map +14 -0
- package/dist/chunks/{studio-cx91mnh8.js → studio-ckwz4d3x.js} +53 -53
- package/dist/chunks/{studio-cx91mnh8.js.map → studio-ckwz4d3x.js.map} +5 -5
- package/dist/chunks/studio-dg7zyt5x.js +28 -0
- package/dist/chunks/studio-dg7zyt5x.js.map +10 -0
- package/dist/chunks/studio-dzcyvvvz.js +166 -0
- package/dist/chunks/studio-dzcyvvvz.js.map +10 -0
- package/dist/chunks/{studio-7kqe41sa.js → studio-evkr5d3b.js} +15 -7
- package/dist/chunks/{studio-7kqe41sa.js.map → studio-evkr5d3b.js.map} +5 -4
- package/dist/chunks/studio-f12h6177.js +411 -0
- package/dist/chunks/studio-f12h6177.js.map +13 -0
- package/dist/chunks/studio-hbp1s8m9.js +1817 -0
- package/dist/chunks/studio-hbp1s8m9.js.map +14 -0
- package/dist/chunks/studio-k2sxjph5.js +15295 -0
- package/dist/chunks/studio-k2sxjph5.js.map +235 -0
- package/dist/chunks/studio-myje51t3.js +385 -0
- package/dist/chunks/studio-myje51t3.js.map +12 -0
- package/dist/chunks/studio-na6rwzhw.js +353 -0
- package/dist/chunks/studio-na6rwzhw.js.map +11 -0
- package/dist/chunks/{studio-4k0m4fmp.js → studio-p471gg5n.js} +126 -3
- package/dist/chunks/studio-p471gg5n.js.map +14 -0
- package/dist/chunks/{studio-9bjwf0d4.js → studio-rgwcpast.js} +5 -46
- package/dist/chunks/{studio-9bjwf0d4.js.map → studio-rgwcpast.js.map} +4 -5
- package/dist/chunks/{studio-esrewxap.js → studio-s519zc99.js} +3 -3
- package/dist/chunks/studio-sas7mt3k.js +75 -0
- package/dist/chunks/studio-sas7mt3k.js.map +10 -0
- package/dist/chunks/studio-t5wqwzdh.js +74 -0
- package/dist/chunks/studio-t5wqwzdh.js.map +10 -0
- package/dist/chunks/{studio-9qpspwfn.js → studio-temv5bw4.js} +5 -5
- package/dist/chunks/{studio-9qpspwfn.js.map → studio-temv5bw4.js.map} +1 -1
- package/dist/chunks/{studio-qkvt37c0.js → studio-tr20m8g8.js} +669 -18
- package/dist/chunks/studio-tr20m8g8.js.map +9 -0
- package/dist/chunks/{studio-v7jkjqng.js → studio-v96qfnww.js} +5514 -4291
- package/dist/chunks/studio-v96qfnww.js.map +54 -0
- package/dist/chunks/studio-vcetc91k.js +659 -0
- package/dist/chunks/studio-vcetc91k.js.map +10 -0
- package/dist/chunks/{studio-jm0z592w.js → studio-vmymjefp.js} +2972 -4937
- package/dist/chunks/studio-vmymjefp.js.map +98 -0
- package/dist/chunks/{studio-bghqfmea.js → studio-wwseqyw0.js} +2 -2
- package/dist/chunks/studio-x9hmy5kr.js +390 -0
- package/dist/chunks/studio-x9hmy5kr.js.map +10 -0
- package/dist/chunks/studio-xa0ce0n7.js +335 -0
- package/dist/chunks/studio-xa0ce0n7.js.map +10 -0
- package/dist/chunks/studio-xm3dbxvn.js +378 -0
- package/dist/chunks/studio-xm3dbxvn.js.map +19 -0
- package/dist/chunks/{studio-nqw433kv.js → studio-xmvbhg31.js} +2 -2
- package/dist/chunks/studio-zv37yrfa.js +1054 -0
- package/dist/chunks/studio-zv37yrfa.js.map +13 -0
- package/dist/chunks/{ws-client-q7hkz3j9.js → ws-client-654ts66j.js} +6 -4
- package/dist/chunks/ws-client-654ts66j.js.map +11 -0
- package/dist/iframe-entry.js +668 -230
- package/dist/studio.js +21080 -32585
- package/index.html +35 -4685
- package/package.json +17 -15
- package/src/about/about-modal.ts +42 -11
- package/src/browse/library-commands.ts +240 -0
- package/src/browse/library-layouts.ts +369 -0
- package/src/browse/library-model.ts +396 -0
- package/src/browse/library-pane.ts +1049 -0
- package/src/browse/library-preview.ts +252 -0
- package/src/browse/library-source.ts +150 -0
- package/src/canvas/canvas-helpers.ts +33 -9
- package/src/canvas/canvas-live-render.ts +45 -29
- package/src/canvas/canvas-patcher.ts +151 -31
- package/src/canvas/canvas-perf.ts +46 -7
- package/src/canvas/canvas-render.ts +1092 -220
- package/src/canvas/canvas-surface.ts +240 -0
- package/src/canvas/canvas-utils.ts +1166 -124
- package/src/canvas/iframe-drop.ts +2 -1
- package/src/canvas/iframe-entry.ts +241 -4
- package/src/canvas/iframe-host.ts +1226 -143
- package/src/canvas/iframe-inline-edit.ts +31 -0
- package/src/canvas/iframe-interaction.ts +76 -16
- package/src/canvas/iframe-keys.ts +107 -45
- package/src/canvas/iframe-overlay.ts +38 -1
- package/src/canvas/iframe-protocol.ts +137 -2
- package/src/canvas/iframe-render.ts +70 -2
- package/src/canvas/iframe-slash.ts +6 -0
- package/src/canvas/path-mapping.ts +56 -6
- package/src/canvas/surface-registry.ts +292 -0
- package/src/collab/collab-commands.ts +244 -0
- package/src/collab/collab-session.ts +198 -32
- package/src/collab/collab-state.ts +28 -2
- package/src/collab/presence-chips.ts +100 -9
- package/src/commands/active-registry.ts +42 -0
- package/src/commands/app-commands.ts +176 -0
- package/src/commands/budget.ts +158 -0
- package/src/commands/command-args.ts +253 -0
- package/src/commands/context.ts +387 -0
- package/src/commands/defaults.ts +580 -0
- package/src/commands/keymap.ts +571 -0
- package/src/commands/levels.ts +345 -0
- package/src/commands/live-context.ts +193 -0
- package/src/commands/reference.ts +227 -0
- package/src/commands/registry.ts +293 -0
- package/src/component-props.ts +3 -2
- package/src/content/draft-state.ts +106 -0
- package/src/content/entry-commands.ts +282 -0
- package/src/content/entry-editor.ts +327 -0
- package/src/content/entry-fields.ts +116 -0
- package/src/content/entry-model.ts +175 -0
- package/src/editor/canvas-context-menu.ts +17 -11
- package/src/editor/canvas-slash-bridge.ts +11 -4
- package/src/editor/context-menu.ts +804 -204
- package/src/editor/convert-to-component.ts +31 -10
- package/src/editor/convert-to-repeater.ts +3 -2
- package/src/editor/inline-edit-apply.ts +5 -5
- package/src/editor/inline-edit.ts +86 -50
- package/src/editor/insert-zone-action.ts +1 -1
- package/src/editor/shortcuts.ts +1043 -329
- package/src/editor/slash-menu.ts +1 -0
- package/src/files/elements.ts +166 -0
- package/src/files/file-ops.ts +178 -31
- package/src/files/files.ts +898 -268
- package/src/files/fs-events.ts +21 -0
- package/src/files/media-meta.ts +203 -0
- package/src/files/media-paths.ts +141 -0
- package/src/files/media-upload.ts +7 -3
- package/src/files/media-usage.ts +189 -0
- package/src/github/github-auth.ts +235 -33
- package/src/github/github-publish.ts +80 -21
- package/src/grid/grid-controller.ts +152 -12
- package/src/grid/grid-layout.ts +225 -20
- package/src/grid/grid-open.ts +120 -0
- package/src/grid/grid-panel.ts +554 -39
- package/src/grid/grid-source.ts +27 -1
- package/src/grid/redirects-grid.ts +599 -0
- package/src/grid/redirects.ts +398 -0
- package/src/grid/sources/content-source.ts +108 -2
- package/src/i18n/i18n-commands.ts +322 -0
- package/src/new-project/add-repo-modal.ts +8 -10
- package/src/new-project/import-tab.ts +7 -0
- package/src/new-project/location-fields.ts +9 -1
- package/src/new-project/new-project-modal.ts +221 -245
- package/src/packages/jxsuite-update.ts +97 -48
- package/src/packages/pull-package-sync.ts +18 -9
- package/src/page-params.ts +2 -1
- package/src/panels/activity-bar.ts +152 -91
- package/src/panels/activity-panel.ts +481 -0
- package/src/panels/ai-chat/chat-markdown.ts +8 -3
- package/src/panels/ai-chat/chat-view.ts +245 -18
- package/src/panels/ai-chat/composer.ts +31 -5
- package/src/panels/ai-chat/sessions-view.ts +17 -10
- package/src/panels/ai-panel.ts +279 -53
- package/src/panels/block-action-bar.ts +1115 -224
- package/src/panels/bottom-dock.ts +337 -0
- package/src/panels/canvas-dnd-bridge.ts +37 -8
- package/src/panels/chat-panel.ts +29 -12
- package/src/panels/data-explorer.ts +254 -112
- package/src/panels/data-grid.ts +10 -4
- package/src/panels/dnd.ts +28 -51
- package/src/panels/editors.ts +434 -107
- package/src/panels/elements-panel.ts +66 -2
- package/src/panels/empty-state.ts +112 -0
- package/src/panels/events-panel.ts +596 -82
- package/src/panels/formula-workspace.ts +408 -218
- package/src/panels/frontmatter-fields.ts +51 -10
- package/src/panels/frontmatter-panel.ts +357 -79
- package/src/panels/git-panel.ts +375 -164
- package/src/panels/head-panel.ts +791 -85
- package/src/panels/i18n-panel.ts +592 -0
- package/src/panels/imports-panel.ts +95 -85
- package/src/panels/jump-bar.ts +584 -0
- package/src/panels/layers-panel.ts +1106 -328
- package/src/panels/left-panel.ts +109 -204
- package/src/panels/navigator-panels.ts +131 -0
- package/src/panels/overlays.ts +7 -2
- package/src/panels/pane-context.ts +1477 -0
- package/src/panels/pane-grid.ts +406 -0
- package/src/panels/panel-registry.ts +340 -0
- package/src/panels/panel-scheduler.ts +75 -0
- package/src/panels/problems-panel.ts +234 -0
- package/src/panels/properties-panel.ts +946 -1139
- package/src/panels/provenance.ts +258 -0
- package/src/panels/quick-search.ts +791 -122
- package/src/panels/right-panel.ts +228 -102
- package/src/panels/seo-modal.ts +395 -0
- package/src/panels/settings-pane.ts +203 -0
- package/src/panels/signals-panel.ts +354 -137
- package/src/panels/statement-editor.ts +41 -25
- package/src/panels/statusbar.ts +342 -106
- package/src/panels/style-panel.ts +974 -463
- package/src/panels/style-utils.ts +3 -3
- package/src/panels/stylebook-doc.ts +4 -2
- package/src/panels/stylebook-layers-panel.ts +3 -2
- package/src/panels/stylebook-panel.ts +51 -26
- package/src/panels/tab-strip.ts +1058 -78
- package/src/panels/target-line.ts +274 -0
- package/src/panels/toolbar.ts +631 -484
- package/src/panels/welcome-screen.ts +301 -198
- package/src/platform-errors.ts +26 -3
- package/src/platform.ts +96 -2
- package/src/platforms/cloud.ts +71 -8
- package/src/platforms/devserver.ts +92 -29
- package/src/publish/deploy-checklist.ts +383 -0
- package/src/publish/pages-service.ts +10 -1
- package/src/publish/publish-commands.ts +218 -0
- package/src/publish/publish-panel.ts +110 -16
- package/src/recent-projects.ts +3 -2
- package/src/services/a11y-report.ts +505 -0
- package/src/services/ai-models.ts +30 -1
- package/src/services/ai-project-tools.ts +29 -10
- package/src/services/ai-system-prompt.ts +53 -14
- package/src/services/ai-tools.ts +39 -3
- package/src/services/ai-writes.ts +154 -0
- package/src/services/announce.ts +97 -0
- package/src/services/automation.ts +424 -175
- package/src/services/cem-export.ts +9 -4
- package/src/services/clock.ts +61 -0
- package/src/services/document-assistant.ts +36 -8
- package/src/services/idle.ts +179 -0
- package/src/services/import-client.ts +26 -0
- package/src/services/monaco-buffer.ts +627 -0
- package/src/services/monaco-lazy.ts +36 -0
- package/src/services/notify.ts +271 -0
- package/src/services/profile.ts +155 -0
- package/src/services/references.ts +0 -0
- package/src/services/token-lint.ts +3 -2
- package/src/services/tool-executor.ts +53 -7
- package/src/services/trusted-types.ts +123 -0
- package/src/settings/contexts-section.ts +427 -0
- package/src/settings/contributed-section.ts +149 -11
- package/src/settings/css-vars-editor.ts +306 -349
- package/src/settings/defs-editor.ts +93 -34
- package/src/settings/dependencies-editor.ts +7 -7
- package/src/settings/extension-sections.ts +37 -5
- package/src/settings/general-settings.ts +204 -121
- package/src/settings/locales-section.ts +347 -0
- package/src/settings/preferences-accounts.ts +140 -0
- package/src/settings/preferences-dialog.ts +620 -0
- package/src/settings/preferences-keymap.ts +270 -0
- package/src/settings/project-sections.ts +260 -0
- package/src/settings/section-registry.ts +169 -0
- package/src/settings/settings-document.ts +385 -0
- package/src/shell.ts +1549 -0
- package/src/site-context.ts +45 -20
- package/src/state.ts +14 -275
- package/src/store.ts +88 -43
- package/src/studio.ts +790 -346
- package/src/style/live-preview.ts +36 -0
- package/src/style/project-styles.ts +349 -0
- package/src/style/token-ref.ts +123 -0
- package/src/tabs/patch-ops.ts +16 -4
- package/src/tabs/project-config.ts +416 -0
- package/src/tabs/selection.ts +323 -0
- package/src/tabs/tab.ts +182 -47
- package/src/tabs/transact.ts +209 -28
- package/src/types.ts +68 -13
- package/src/ui/ai-credentials-form.ts +39 -35
- package/src/ui/ai-managed-connect.ts +7 -1
- package/src/ui/dynamic-slot.ts +224 -81
- package/src/ui/expression-editor.ts +71 -63
- package/src/ui/field-input.ts +4 -6
- package/src/ui/field-row.ts +215 -38
- package/src/ui/form-controls.ts +194 -7
- package/src/ui/layers.ts +492 -69
- package/src/ui/media-picker.ts +115 -25
- package/src/ui/panel-resize.ts +109 -91
- package/src/ui/progress-modal.ts +131 -50
- package/src/ui/regions.ts +462 -0
- package/src/ui/schema-form.ts +229 -45
- package/src/ui/spectrum.ts +24 -8
- package/src/ui/timing.ts +49 -0
- package/src/ui/value-selector.ts +20 -0
- package/src/ui/value-source.ts +348 -0
- package/src/ui/virtual-window.ts +400 -0
- package/src/utils/edit-display.ts +3 -2
- package/src/utils/inherited-style.ts +67 -37
- package/src/view.ts +64 -78
- package/src/workspace/pane-derive.ts +1258 -0
- package/src/workspace/session.ts +317 -0
- package/src/workspace/workspace.ts +1345 -23
- package/styles/canvas.css +653 -0
- package/styles/forced-colors.css +106 -0
- package/styles/inspector.css +1454 -0
- package/styles/overlays.css +2704 -0
- package/styles/panels.css +1701 -0
- package/styles/shell.css +1423 -0
- package/styles/tokens.css +151 -0
- package/dist/chunks/Overlay.dev-cszx7yn5.js +0 -22
- package/dist/chunks/extension-sections-qr3drkkw.js +0 -531
- package/dist/chunks/extension-sections-qr3drkkw.js.map +0 -12
- package/dist/chunks/files-j8a1t06k.js +0 -50
- package/dist/chunks/files-j8a1t06k.js.map +0 -9
- package/dist/chunks/git-panel-xtq5z81x.js +0 -28
- package/dist/chunks/git-panel-xtq5z81x.js.map +0 -9
- package/dist/chunks/layers-panel-ancn2wpq.js +0 -26
- package/dist/chunks/layers-panel-ancn2wpq.js.map +0 -9
- package/dist/chunks/settings-modal-17ttr96g.js +0 -29
- package/dist/chunks/settings-modal-17ttr96g.js.map +0 -9
- package/dist/chunks/sp-overlay.dev-earn5as8.js +0 -17
- package/dist/chunks/sp-overlay.dev-earn5as8.js.map +0 -9
- package/dist/chunks/sp-popover.dev-frxtfejn.js +0 -8
- package/dist/chunks/sp-popover.dev-frxtfejn.js.map +0 -9
- package/dist/chunks/sp-progress-circle.dev-7tvw34h8.js +0 -14
- package/dist/chunks/sp-progress-circle.dev-7tvw34h8.js.map +0 -9
- package/dist/chunks/sp-tooltip.dev-a9fywesm.js +0 -21
- package/dist/chunks/sp-tooltip.dev-a9fywesm.js.map +0 -10
- package/dist/chunks/studio-0xgs7xrs.js +0 -429
- package/dist/chunks/studio-0xgs7xrs.js.map +0 -13
- package/dist/chunks/studio-4d8hzcz5.js +0 -3427
- package/dist/chunks/studio-4d8hzcz5.js.map +0 -26
- package/dist/chunks/studio-4k0m4fmp.js.map +0 -13
- package/dist/chunks/studio-9smfzqhc.js +0 -19
- package/dist/chunks/studio-9smfzqhc.js.map +0 -10
- package/dist/chunks/studio-jm0z592w.js.map +0 -115
- package/dist/chunks/studio-m2t4mdg5.js +0 -1854
- package/dist/chunks/studio-m2t4mdg5.js.map +0 -18
- package/dist/chunks/studio-n4p11dfy.js +0 -853
- package/dist/chunks/studio-n4p11dfy.js.map +0 -12
- package/dist/chunks/studio-qkvt37c0.js.map +0 -9
- package/dist/chunks/studio-sjh1wt1a.js +0 -3744
- package/dist/chunks/studio-sjh1wt1a.js.map +0 -22
- package/dist/chunks/studio-v7jkjqng.js.map +0 -46
- package/dist/chunks/studio-xq0t7n5t.js +0 -140
- package/dist/chunks/studio-xq0t7n5t.js.map +0 -10
- package/dist/chunks/studio-xze4zrs1.js.map +0 -10
- package/dist/chunks/studio-yfcstdzy.js +0 -399
- package/dist/chunks/studio-yfcstdzy.js.map +0 -12
- package/dist/chunks/studio-zp7jncs8.js +0 -469
- package/dist/chunks/studio-zp7jncs8.js.map +0 -14
- package/dist/chunks/ws-client-q7hkz3j9.js.map +0 -11
- package/src/browse/browse-modal.ts +0 -69
- package/src/browse/browse.ts +0 -904
- package/src/canvas/canvas-diff.ts +0 -216
- package/src/canvas/nested-site-style.ts +0 -58
- package/src/new-project/design-fields.ts +0 -264
- package/src/new-project/templates.ts +0 -61
- package/src/panels/tab-bar.ts +0 -548
- package/src/settings/settings-modal.ts +0 -203
- /package/dist/chunks/{adapter-z8fkmbmv.js.map → adapter-qfnesn5v.js.map} +0 -0
- /package/dist/chunks/{cf-settings-mmyv436d.js.map → cf-settings-73sh5z4p.js.map} +0 -0
- /package/dist/chunks/{format-host-8jxwts2p.js.map → format-host-mkwsmt8d.js.map} +0 -0
- /package/dist/chunks/{jx-validate-qx8degj7.js.map → jx-validate-v6qgknrs.js.map} +0 -0
- /package/dist/chunks/{studio-w7c6kvk7.js.map → studio-a7pzveq1.js.map} +0 -0
- /package/dist/chunks/{studio-esrewxap.js.map → studio-s519zc99.js.map} +0 -0
- /package/dist/chunks/{studio-bghqfmea.js.map → studio-wwseqyw0.js.map} +0 -0
- /package/dist/chunks/{studio-nqw433kv.js.map → studio-xmvbhg31.js.map} +0 -0
package/src/panels/tab-strip.ts
CHANGED
|
@@ -1,125 +1,465 @@
|
|
|
1
1
|
/// <reference lib="dom" />
|
|
2
2
|
/**
|
|
3
|
-
* Tab strip —
|
|
3
|
+
* Tab strip — one strip PER PANE, above each pane's editor.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
33
|
-
|
|
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.
|
|
38
|
-
|
|
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
|
-
|
|
166
|
+
_primaryHost = null;
|
|
167
|
+
_dragging = null;
|
|
168
|
+
_lastActive.clear();
|
|
169
|
+
_overflowing.clear();
|
|
170
|
+
_hosts.clear();
|
|
52
171
|
}
|
|
53
172
|
|
|
54
173
|
function render() {
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
321
|
+
host,
|
|
109
322
|
);
|
|
110
323
|
|
|
111
|
-
if (
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
1177
|
+
return true;
|
|
198
1178
|
}
|