sourcemonkey 0.0.0-stage → 0.5.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 (367) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +87 -2
  3. package/dist/bytes.js +37 -0
  4. package/dist/channel-placeholder.js +5 -0
  5. package/dist/collection-scan.js +17 -0
  6. package/dist/collections.html +353 -0
  7. package/dist/compare.html +358 -0
  8. package/dist/composed-source.js +205 -0
  9. package/dist/connect-prompt.html +47 -0
  10. package/dist/debug/icons.html +211 -0
  11. package/dist/debug/storage.html +163 -0
  12. package/dist/debug-log.js +75 -0
  13. package/dist/diagnostic-location.js +64 -0
  14. package/dist/eval-hook.js +308 -0
  15. package/dist/extension/background.js +5263 -0
  16. package/dist/extension/channel.js +133 -0
  17. package/dist/extension/code-links.js +187 -0
  18. package/dist/extension/code-tabs.js +460 -0
  19. package/dist/extension/collection-dialog.js +619 -0
  20. package/dist/extension/collection-display.js +322 -0
  21. package/dist/extension/collections.js +2905 -0
  22. package/dist/extension/compare.js +867 -0
  23. package/dist/extension/concurrency.js +186 -0
  24. package/dist/extension/connect-grants.js +109 -0
  25. package/dist/extension/connect-prompt-page.js +70 -0
  26. package/dist/extension/connect-prompt.js +266 -0
  27. package/dist/extension/copy-button.js +63 -0
  28. package/dist/extension/copy-dialog.js +502 -0
  29. package/dist/extension/copy-preference.js +66 -0
  30. package/dist/extension/debug/icons.js +203 -0
  31. package/dist/extension/debug/storage.js +365 -0
  32. package/dist/extension/deep-link.js +77 -0
  33. package/dist/extension/delete-dialog.js +432 -0
  34. package/dist/extension/delete-icon.js +40 -0
  35. package/dist/extension/details-tooltips.js +66 -0
  36. package/dist/extension/directories.js +679 -0
  37. package/dist/extension/export-backup.js +407 -0
  38. package/dist/extension/export-dialog.js +142 -0
  39. package/dist/extension/file-access.js +74 -0
  40. package/dist/extension/file-changes.js +346 -0
  41. package/dist/extension/frame-info.js +85 -0
  42. package/dist/extension/github-provider.js +336 -0
  43. package/dist/extension/glyph.js +84 -0
  44. package/dist/extension/gm-bindings.js +601 -0
  45. package/dist/extension/gm-download.js +419 -0
  46. package/dist/extension/gm-menu.js +228 -0
  47. package/dist/extension/gm-notification.js +141 -0
  48. package/dist/extension/gm-originator-map.js +23 -0
  49. package/dist/extension/gm-tab-data.js +121 -0
  50. package/dist/extension/gm-tab.js +160 -0
  51. package/dist/extension/gm-url-change.js +111 -0
  52. package/dist/extension/gm-url-scheme.js +27 -0
  53. package/dist/extension/gm-values.js +176 -0
  54. package/dist/extension/gm-window-control.js +104 -0
  55. package/dist/extension/gm-xhr-headers.js +333 -0
  56. package/dist/extension/gm-xhr.js +584 -0
  57. package/dist/extension/injector.js +3145 -0
  58. package/dist/extension/install-capture.js +277 -0
  59. package/dist/extension/line-diff.js +233 -0
  60. package/dist/extension/local-copy.js +560 -0
  61. package/dist/extension/local-delete.js +276 -0
  62. package/dist/extension/location-kinds.js +108 -0
  63. package/dist/extension/log-format.js +158 -0
  64. package/dist/extension/managed-changes.js +361 -0
  65. package/dist/extension/managed-items.js +57 -0
  66. package/dist/extension/match-index.js +375 -0
  67. package/dist/extension/missing-scripts.js +66 -0
  68. package/dist/extension/more-menu.js +109 -0
  69. package/dist/extension/move-dialog.js +197 -0
  70. package/dist/extension/offscreen.js +37 -0
  71. package/dist/extension/options.js +504 -0
  72. package/dist/extension/page-heading.js +170 -0
  73. package/dist/extension/page-links.js +199 -0
  74. package/dist/extension/parse-check-client.js +70 -0
  75. package/dist/extension/parse-check.js +161 -0
  76. package/dist/extension/parse-sandbox-host.js +172 -0
  77. package/dist/extension/parse-sandbox.js +175 -0
  78. package/dist/extension/popup-commands-open.js +41 -0
  79. package/dist/extension/popup-view.js +387 -0
  80. package/dist/extension/popup.js +703 -0
  81. package/dist/extension/prism-userscript.js +53 -0
  82. package/dist/extension/refresh-error.js +136 -0
  83. package/dist/extension/refresh-targets.js +160 -0
  84. package/dist/extension/related-versions.js +1144 -0
  85. package/dist/extension/resize-observer-loop.js +19 -0
  86. package/dist/extension/row-cache.js +387 -0
  87. package/dist/extension/script-blobs.js +413 -0
  88. package/dist/extension/script-details.js +658 -0
  89. package/dist/extension/script-enablement.js +285 -0
  90. package/dist/extension/script-identity.js +310 -0
  91. package/dist/extension/script-keys.js +54 -0
  92. package/dist/extension/script-log-id.js +155 -0
  93. package/dist/extension/script-log.js +290 -0
  94. package/dist/extension/script-rejection.js +104 -0
  95. package/dist/extension/script-sites.js +158 -0
  96. package/dist/extension/script-store.js +3507 -0
  97. package/dist/extension/script-updater.js +991 -0
  98. package/dist/extension/script-url-hash.js +61 -0
  99. package/dist/extension/select-all.js +37 -0
  100. package/dist/extension/select-open-guard.js +90 -0
  101. package/dist/extension/site-icon.js +1261 -0
  102. package/dist/extension/sm-dev-link.js +302 -0
  103. package/dist/extension/solo-click.js +58 -0
  104. package/dist/extension/sticky-numbers.js +188 -0
  105. package/dist/extension/storage-breakdown.js +253 -0
  106. package/dist/extension/storage-scan.js +86 -0
  107. package/dist/extension/storage-usage.js +194 -0
  108. package/dist/extension/structural-keys.js +29 -0
  109. package/dist/extension/sw-teardown.js +36 -0
  110. package/dist/extension/tab-status.js +403 -0
  111. package/dist/extension/toolbar-summary.js +105 -0
  112. package/dist/extension/user-scripts-access.js +43 -0
  113. package/dist/extension/userscript.js +3672 -0
  114. package/dist/extension/value-store.js +204 -0
  115. package/dist/extension/version-probe-fetch.js +68 -0
  116. package/dist/extension/worker-client.js +109 -0
  117. package/dist/extension/worker-ops.js +171 -0
  118. package/dist/extension/zip-writer.js +24 -0
  119. package/dist/external-files.js +878 -0
  120. package/dist/file-loader.js +436 -0
  121. package/dist/file-locations.js +439 -0
  122. package/dist/generated-wrappers.js +5 -0
  123. package/dist/github-auth.js +148 -0
  124. package/dist/github-urls.js +46 -0
  125. package/dist/gm-api-catalog.js +217 -0
  126. package/dist/gm-connect.js +273 -0
  127. package/dist/gm-xhr-types.js +5 -0
  128. package/dist/http-read.js +142 -0
  129. package/dist/icons/icon-128.png +0 -0
  130. package/dist/icons/icon-16.png +0 -0
  131. package/dist/icons/icon-48.png +0 -0
  132. package/dist/local-file-overrides.js +558 -0
  133. package/dist/manifest.json +38 -0
  134. package/dist/markup-response.js +126 -0
  135. package/dist/match-targets.js +305 -0
  136. package/dist/menu-captions.js +22 -0
  137. package/dist/offscreen.html +39 -0
  138. package/dist/options.html +197 -0
  139. package/dist/parse-check-protocol.js +51 -0
  140. package/dist/parse-location.js +171 -0
  141. package/dist/parse-sandbox.html +56 -0
  142. package/dist/phase-timing.js +155 -0
  143. package/dist/popup.html +197 -0
  144. package/dist/public-suffixes.js +6957 -0
  145. package/dist/run-context.js +31 -0
  146. package/dist/runtime-messages.js +34 -0
  147. package/dist/sandboxed-page.js +35 -0
  148. package/dist/script-build.js +787 -0
  149. package/dist/script-display.js +383 -0
  150. package/dist/script-icon.js +43 -0
  151. package/dist/script-links.js +204 -0
  152. package/dist/script-source-url.js +214 -0
  153. package/dist/script-world.js +65 -0
  154. package/dist/sm-dev-protocol.js +84 -0
  155. package/dist/source-map.js +177 -0
  156. package/dist/stack-link.js +138 -0
  157. package/dist/styles.css +825 -0
  158. package/dist/unsafe-window-use.js +229 -0
  159. package/dist/url-match.js +734 -0
  160. package/dist/userscript-header.js +248 -0
  161. package/dist/userscript.html +1165 -0
  162. package/dist/vendor/fflate.LICENSE +21 -0
  163. package/dist/vendor/fflate.js +2692 -0
  164. package/dist/vendor/prism/prism-line-numbers.css +1 -0
  165. package/dist/vendor/prism/prism-line-numbers.js +1 -0
  166. package/dist/vendor/prism/prism.css +1 -0
  167. package/dist/vendor/prism/prism.js +1946 -0
  168. package/dist/version-compare.js +141 -0
  169. package/dist/version-probe.js +120 -0
  170. package/lib/build-info.json +1 -0
  171. package/lib/bytes.d.ts +3 -0
  172. package/lib/bytes.js +37 -0
  173. package/lib/channel-placeholder.d.ts +1 -0
  174. package/lib/channel-placeholder.js +5 -0
  175. package/lib/collection-scan.d.ts +8 -0
  176. package/lib/collection-scan.js +17 -0
  177. package/lib/composed-source.d.ts +28 -0
  178. package/lib/composed-source.js +205 -0
  179. package/lib/debug-log.d.ts +9 -0
  180. package/lib/debug-log.js +75 -0
  181. package/lib/diagnostic-location.d.ts +8 -0
  182. package/lib/diagnostic-location.js +64 -0
  183. package/lib/eval-hook.d.ts +34 -0
  184. package/lib/eval-hook.js +308 -0
  185. package/lib/external-files.d.ts +111 -0
  186. package/lib/external-files.js +878 -0
  187. package/lib/file-loader.d.ts +48 -0
  188. package/lib/file-loader.js +436 -0
  189. package/lib/file-locations.d.ts +44 -0
  190. package/lib/file-locations.js +439 -0
  191. package/lib/generated-wrappers.d.ts +11 -0
  192. package/lib/generated-wrappers.js +5 -0
  193. package/lib/github-auth.d.ts +12 -0
  194. package/lib/github-auth.js +148 -0
  195. package/lib/github-urls.d.ts +1 -0
  196. package/lib/github-urls.js +46 -0
  197. package/lib/gm-api-catalog.d.ts +24 -0
  198. package/lib/gm-api-catalog.js +217 -0
  199. package/lib/gm-connect.d.ts +34 -0
  200. package/lib/gm-connect.js +273 -0
  201. package/lib/gm-xhr-types.d.ts +68 -0
  202. package/lib/gm-xhr-types.js +5 -0
  203. package/lib/harness/browser-extensions.d.ts +73 -0
  204. package/lib/harness/browser-extensions.js +285 -0
  205. package/lib/harness/browser-lang.d.ts +20 -0
  206. package/lib/harness/browser-lang.js +86 -0
  207. package/lib/harness/browser-launch.d.ts +14 -0
  208. package/lib/harness/browser-launch.js +184 -0
  209. package/lib/harness/checks.d.ts +110 -0
  210. package/lib/harness/checks.js +321 -0
  211. package/lib/harness/cli.d.ts +2 -0
  212. package/lib/harness/cli.js +2259 -0
  213. package/lib/harness/command-args.d.ts +91 -0
  214. package/lib/harness/command-args.js +369 -0
  215. package/lib/harness/command-lines.d.ts +80 -0
  216. package/lib/harness/command-lines.js +180 -0
  217. package/lib/harness/command-list.d.ts +206 -0
  218. package/lib/harness/command-list.js +1475 -0
  219. package/lib/harness/console-commands.d.ts +463 -0
  220. package/lib/harness/console-commands.js +3216 -0
  221. package/lib/harness/download-watch.d.ts +21 -0
  222. package/lib/harness/download-watch.js +156 -0
  223. package/lib/harness/extension-backend.d.ts +41 -0
  224. package/lib/harness/extension-backend.js +100 -0
  225. package/lib/harness/extension-host.d.ts +112 -0
  226. package/lib/harness/extension-host.js +552 -0
  227. package/lib/harness/extension-launch.d.ts +67 -0
  228. package/lib/harness/extension-launch.js +285 -0
  229. package/lib/harness/extension-link.d.ts +315 -0
  230. package/lib/harness/extension-link.js +607 -0
  231. package/lib/harness/external-manager.d.ts +146 -0
  232. package/lib/harness/external-manager.js +414 -0
  233. package/lib/harness/fixture.d.ts +165 -0
  234. package/lib/harness/fixture.js +461 -0
  235. package/lib/harness/frame-sessions.d.ts +71 -0
  236. package/lib/harness/frame-sessions.js +248 -0
  237. package/lib/harness/frames.d.ts +68 -0
  238. package/lib/harness/frames.js +216 -0
  239. package/lib/harness/help-format.d.ts +17 -0
  240. package/lib/harness/help-format.js +127 -0
  241. package/lib/harness/host.d.ts +352 -0
  242. package/lib/harness/host.js +423 -0
  243. package/lib/harness/index.d.ts +8 -0
  244. package/lib/harness/index.js +10 -0
  245. package/lib/harness/input-file.d.ts +18 -0
  246. package/lib/harness/input-file.js +61 -0
  247. package/lib/harness/json-output.d.ts +565 -0
  248. package/lib/harness/json-output.js +71 -0
  249. package/lib/harness/manager-actions.d.ts +21 -0
  250. package/lib/harness/manager-actions.js +129 -0
  251. package/lib/harness/manager-commands.d.ts +142 -0
  252. package/lib/harness/manager-commands.js +1196 -0
  253. package/lib/harness/manager-fetch.d.ts +33 -0
  254. package/lib/harness/manager-fetch.js +239 -0
  255. package/lib/harness/managers.d.ts +24 -0
  256. package/lib/harness/managers.js +27 -0
  257. package/lib/harness/output-style.d.ts +125 -0
  258. package/lib/harness/output-style.js +322 -0
  259. package/lib/harness/page-commands.d.ts +134 -0
  260. package/lib/harness/page-commands.js +1141 -0
  261. package/lib/harness/page-output.d.ts +67 -0
  262. package/lib/harness/page-output.js +139 -0
  263. package/lib/harness/page-watch.d.ts +171 -0
  264. package/lib/harness/page-watch.js +607 -0
  265. package/lib/harness/parse-check-node.d.ts +3 -0
  266. package/lib/harness/parse-check-node.js +132 -0
  267. package/lib/harness/path-args.d.ts +22 -0
  268. package/lib/harness/path-args.js +148 -0
  269. package/lib/harness/playwright-chromium.d.ts +16 -0
  270. package/lib/harness/playwright-chromium.js +86 -0
  271. package/lib/harness/prepare.d.ts +168 -0
  272. package/lib/harness/prepare.js +364 -0
  273. package/lib/harness/prompt-editor.d.ts +17 -0
  274. package/lib/harness/prompt-editor.js +354 -0
  275. package/lib/harness/prompt-history.d.ts +13 -0
  276. package/lib/harness/prompt-history.js +89 -0
  277. package/lib/harness/rebuild-advice.d.ts +6 -0
  278. package/lib/harness/rebuild-advice.js +39 -0
  279. package/lib/harness/run-log.d.ts +47 -0
  280. package/lib/harness/run-log.js +140 -0
  281. package/lib/harness/run-logs.d.ts +132 -0
  282. package/lib/harness/run-logs.js +241 -0
  283. package/lib/harness/run.d.ts +194 -0
  284. package/lib/harness/run.js +1973 -0
  285. package/lib/harness/runs.d.ts +229 -0
  286. package/lib/harness/runs.js +726 -0
  287. package/lib/harness/save-html.d.ts +39 -0
  288. package/lib/harness/save-html.js +89 -0
  289. package/lib/harness/saved-pages.d.ts +120 -0
  290. package/lib/harness/saved-pages.js +275 -0
  291. package/lib/harness/script-mark.d.ts +17 -0
  292. package/lib/harness/script-mark.js +56 -0
  293. package/lib/harness/snapshot.d.ts +143 -0
  294. package/lib/harness/snapshot.js +474 -0
  295. package/lib/harness/stuck-tabs.d.ts +19 -0
  296. package/lib/harness/stuck-tabs.js +131 -0
  297. package/lib/harness/tab-refs.d.ts +20 -0
  298. package/lib/harness/tab-refs.js +80 -0
  299. package/lib/harness/tampermonkey.d.ts +71 -0
  300. package/lib/harness/tampermonkey.js +356 -0
  301. package/lib/harness/tm-ids.d.ts +18 -0
  302. package/lib/harness/tm-ids.js +88 -0
  303. package/lib/harness/violentmonkey.d.ts +34 -0
  304. package/lib/harness/violentmonkey.js +162 -0
  305. package/lib/harness/wait.d.ts +77 -0
  306. package/lib/harness/wait.js +458 -0
  307. package/lib/http-read.d.ts +30 -0
  308. package/lib/http-read.js +142 -0
  309. package/lib/local-file-overrides.d.ts +35 -0
  310. package/lib/local-file-overrides.js +558 -0
  311. package/lib/manifest.json +38 -0
  312. package/lib/markup-response.d.ts +11 -0
  313. package/lib/markup-response.js +126 -0
  314. package/lib/match-targets.d.ts +18 -0
  315. package/lib/match-targets.js +305 -0
  316. package/lib/menu-captions.d.ts +1 -0
  317. package/lib/menu-captions.js +22 -0
  318. package/lib/parse-check-protocol.d.ts +27 -0
  319. package/lib/parse-check-protocol.js +51 -0
  320. package/lib/parse-location.d.ts +12 -0
  321. package/lib/parse-location.js +171 -0
  322. package/lib/phase-timing.d.ts +12 -0
  323. package/lib/phase-timing.js +155 -0
  324. package/lib/public-suffixes.d.ts +1 -0
  325. package/lib/public-suffixes.js +6957 -0
  326. package/lib/run-context.d.ts +2 -0
  327. package/lib/run-context.js +31 -0
  328. package/lib/runtime-messages.d.ts +10 -0
  329. package/lib/runtime-messages.js +34 -0
  330. package/lib/sandboxed-page.d.ts +5 -0
  331. package/lib/sandboxed-page.js +35 -0
  332. package/lib/script-build.d.ts +111 -0
  333. package/lib/script-build.js +787 -0
  334. package/lib/script-display.d.ts +51 -0
  335. package/lib/script-display.js +383 -0
  336. package/lib/script-icon.d.ts +16 -0
  337. package/lib/script-icon.js +43 -0
  338. package/lib/script-links.d.ts +24 -0
  339. package/lib/script-links.js +204 -0
  340. package/lib/script-source-url.d.ts +8 -0
  341. package/lib/script-source-url.js +214 -0
  342. package/lib/script-world.d.ts +6 -0
  343. package/lib/script-world.js +65 -0
  344. package/lib/sm-dev-protocol.d.ts +251 -0
  345. package/lib/sm-dev-protocol.js +84 -0
  346. package/lib/source-map.d.ts +32 -0
  347. package/lib/source-map.js +177 -0
  348. package/lib/stack-link.d.ts +15 -0
  349. package/lib/stack-link.js +138 -0
  350. package/lib/unsafe-window-use.d.ts +1 -0
  351. package/lib/unsafe-window-use.js +229 -0
  352. package/lib/url-match.d.ts +40 -0
  353. package/lib/url-match.js +734 -0
  354. package/lib/userscript-header.d.ts +33 -0
  355. package/lib/userscript-header.js +248 -0
  356. package/lib/version-compare.d.ts +6 -0
  357. package/lib/version-compare.js +141 -0
  358. package/lib/version-probe.d.ts +17 -0
  359. package/lib/version-probe.js +120 -0
  360. package/package.json +55 -3
  361. package/skills/SourceMonkey-dev/SKILL.md +868 -0
  362. package/skills/SourceMonkey-dev/references/cdp.md +293 -0
  363. package/skills/SourceMonkey-dev/references/commands.md +297 -0
  364. package/skills/SourceMonkey-dev/references/other-managers.md +63 -0
  365. package/skills/SourceMonkey-dev/references/specs.md +46 -0
  366. package/skills/SourceMonkey-friction/SKILL.md +103 -0
  367. package/skills/SourceMonkey-friction/references/template.md +59 -0
@@ -0,0 +1,1144 @@
1
+ // The "Other versions" panel — one table of every installed copy of
2
+ // the script being viewed, shared by the installed-script viewer and
3
+ // the not-yet-installed preview.
4
+ //
5
+ // Both modes render the same rows from the same code. What differs is
6
+ // only which actions each row offers, which the caller supplies as
7
+ // callbacks; the renderer decides *whether* a row can offer them from
8
+ // the row's own relationship to the script on screen:
9
+ //
10
+ // - **Same location** rows are the same bytes, so a refetch is
11
+ // meaningful — refetching that row is refetching this script. The
12
+ // button takes the name the surrounding page already uses for the
13
+ // same call: Refresh in the viewer, Update in a preview.
14
+ // - **Same identity** rows are a different file that happens to be
15
+ // the same script. We cannot install one over the other (a
16
+ // `file://` copy can't overwrite an http one, and two mirrors are
17
+ // two separate subscriptions), so those rows link out and toggle,
18
+ // nothing more.
19
+ //
20
+ // See [docs/script-identity.md].
21
+ import { isContainerCollection, scriptAbsolutePath } from './directories.js';
22
+ import { appendWithSlashBreaks, displayLocation, viewerUrl, } from '../script-display.js';
23
+ import { isScriptEnabled, isCollectionEnabled } from './script-store.js';
24
+ import { COLLECTION_OFF_TITLE, effectiveDisplayName, makeCollectionTypeBubble, collectionAnchorId, collectionHasManifest, } from './collection-display.js';
25
+ import { openExtensionTab, wireNamedTabLink } from './page-links.js';
26
+ import { getValue } from '../userscript-header.js';
27
+ import { guardSelectOpeningClick } from './select-open-guard.js';
28
+ import { setDeleteIcon } from './delete-icon.js';
29
+ import { setGlyphButton } from './glyph.js';
30
+ import { markFileChanges, scriptFileTag, tagLocalFile } from './file-changes.js';
31
+ import { enableTitle, watchSoloModifier } from './solo-click.js';
32
+ import { allRelatedRows, contentFingerprint, enableChanges, otherActiveCopies, } from './script-identity.js';
33
+ // Render into `container`, or clear it when there is nothing related.
34
+ // An empty panel is emptied rather than hidden so the caller's
35
+ // `:empty { display: none }` rule does the hiding — same shape as the
36
+ // details panel next to it.
37
+ export function renderRelatedVersions(container, model) {
38
+ container.replaceChildren();
39
+ const { sameLocation, sameIdentity } = model;
40
+ // No install action means no offer section: an offer whose button
41
+ // the caller has decided against would contradict the page around
42
+ // it. Callers gate on:
43
+ // - installed mode — an Install there would add a SECOND row for a
44
+ // file already installed, and two enabled rows at one URL inject
45
+ // the script twice;
46
+ // - an invalid preview — the page has already said this file
47
+ // cannot be installed.
48
+ const offer = model.actions.install ? model.installOptions : undefined;
49
+ if (!sameLocation.length && !sameIdentity.length) {
50
+ // A preview with nothing installed anywhere has no *existing*
51
+ // versions to head a panel with, and the offer section would only
52
+ // repeat the page's own Install callout one line lower. The
53
+ // installed viewer never reaches this branch — its own row is
54
+ // always in `sameLocation`.
55
+ panelHeadDrawn = false;
56
+ return;
57
+ }
58
+ // A panel whose only row is the script already on screen tells the
59
+ // user nothing the page around it doesn't. The current row earns its
60
+ // place only when there is something to compare it against.
61
+ const onlySelf = sameIdentity.length === 0
62
+ && !offer
63
+ && sameLocation.length === 1
64
+ && sameLocation[0].script.id === model.actions.currentScriptId;
65
+ if (onlySelf) {
66
+ panelHeadDrawn = false;
67
+ return;
68
+ }
69
+ // Every row the panel is about to draw, in one list. Enabling a copy
70
+ // disables the others, and "the others" spans both sections — the
71
+ // point is that one copy of this script runs, not one per section.
72
+ // Same helper the controls-row toggle uses, so both see one list.
73
+ const allRows = allRelatedRows(model);
74
+ // Installed only. A preview draws this table inside the "This script
75
+ // is already installed" banner, whose one line already says what the
76
+ // rows are and what to do with them — a heading under it would be a
77
+ // second, quieter statement of the same thing.
78
+ if (model.mode === 'installed') {
79
+ const head = document.createElement('div');
80
+ head.className = 'versions-head';
81
+ const h2 = document.createElement('h2');
82
+ h2.textContent = 'Other versions of this script';
83
+ head.appendChild(h2);
84
+ // A panel that is only now appearing — a second copy just landed
85
+ // on a page that had none to show — opens, whatever a fold earlier
86
+ // on this page said: the new row is the thing to look at.
87
+ if (!panelHeadDrawn)
88
+ setPanelCollapsed(false);
89
+ panelHeadDrawn = true;
90
+ head.appendChild(collapseToggle(container, model));
91
+ container.appendChild(head);
92
+ if (isPanelCollapsed())
93
+ head.classList.add('collapsed');
94
+ }
95
+ else {
96
+ // A preview draws the rows headless, so there is no headed panel
97
+ // now — and the flag is about the headed one.
98
+ panelHeadDrawn = false;
99
+ }
100
+ // Above the fold: the panel's own failure line is the one thing in
101
+ // here that is time-sensitive, and a report of a refresh that just
102
+ // failed must not be held back until the user happens to unfold.
103
+ if (model.message) {
104
+ const p = document.createElement('p');
105
+ p.className = 'versions-message';
106
+ p.setAttribute('role', 'status');
107
+ p.textContent = model.message;
108
+ container.appendChild(p);
109
+ }
110
+ // Collapsed: the heading line, its toggle, and any message ARE the
111
+ // panel. The rows are left undrawn rather than drawn and hidden —
112
+ // hidden rows would still be found by the panel's keyboard walk and
113
+ // by the repaint's focus bookkeeping, and every repaint would build
114
+ // controls nobody can reach.
115
+ if (model.mode === 'installed' && isPanelCollapsed())
116
+ return;
117
+ // Installed, this section is never empty: the page's own row is a
118
+ // same-location row of the anchor, or the panel re-anchors on it —
119
+ // so the "…all" control built here is always built.
120
+ if (sameLocation.length) {
121
+ // "This URL" rather than "Same URL": the section also covers the
122
+ // row the user is looking at, so it isn't "same as" anything.
123
+ container.appendChild(sectionHeading('This URL'));
124
+ // Every row here loads from the same place, so the refetch means
125
+ // the same thing on all of them — including the current script's
126
+ // own row. That duplicates the button at the top of the viewer,
127
+ // which is fine: a table where one row is missing the control its
128
+ // neighbours have reads as though that row couldn't be refreshed.
129
+ //
130
+ // The "…all" control sits in this first table's header but is the
131
+ // PANEL's: it covers every row `actionsCell` gives a button to —
132
+ // in the installed viewer, the different-URL section too, since a
133
+ // refresh there is the same per-row refetch (`updatableRows`).
134
+ const verb = refetchVerb(model.mode);
135
+ const updatable = updatableRows(model).map((r) => r.script.id);
136
+ const scope = model.mode === 'preview' ? 'all copies at this URL' : 'all copies of this script';
137
+ const table = makeTable({
138
+ headerAction: updatable.length > 1 && model.actions.update
139
+ ? (model.mode === 'preview'
140
+ ? button(`${verb} all`, `${verb} ${scope}`, () => void model.actions.update?.(updatable))
141
+ : glyphButton('#sm-refresh', `${verb} ${scope}`, () => void model.actions.update?.(updatable), `${verb} all`))
142
+ : undefined,
143
+ });
144
+ for (const rel of sameLocation) {
145
+ table.tBodies[0].appendChild(versionRow(rel, model, verb, allRows));
146
+ }
147
+ container.appendChild(table);
148
+ }
149
+ if (sameIdentity.length) {
150
+ container.appendChild(sectionHeading('Same script from a different URL', 'same @name and @namespace'));
151
+ const table = makeTable();
152
+ for (const rel of sameIdentity) {
153
+ table.tBodies[0].appendChild(versionRow(rel, model, refetchVerb(model.mode), allRows));
154
+ }
155
+ container.appendChild(table);
156
+ }
157
+ // Third and last section: the copy you do NOT have yet. A section of
158
+ // its own rather than a row tacked onto "This URL" — it is an offer,
159
+ // not an inventory entry, and inside the inventory its dropdown read
160
+ // as one more cell of data with its button stranded in the actions
161
+ // column at the far right of the table.
162
+ if (offer) {
163
+ container.appendChild(sectionHeading('Install another copy of this script'));
164
+ // The heading row is there but off screen: the section above has
165
+ // already shown the reader these labels, and this table's own
166
+ // left-hand columns are given over to the install controls — but a
167
+ // screen reader has no link between two tables, so the row still
168
+ // needs headers of its own to announce against.
169
+ const table = makeTable({ headings: 'hidden' });
170
+ table.tBodies[0].appendChild(installRow(offer, model));
171
+ container.appendChild(table);
172
+ }
173
+ // Last, once every row all three tables will hold is in the document:
174
+ // the fit is across all of them, and a column measured before its
175
+ // widest row existed would be too narrow for it.
176
+ fitColumns(container);
177
+ // And after that: a chip on any local row whose file has gone or
178
+ // changed (file-changes.ts). Async, so it lands after the fit; the
179
+ // chip is short and the column has room for it.
180
+ void markFileChanges(container);
181
+ }
182
+ // Whether the installed viewer's panel is folded down to its heading
183
+ // line. The table is the page's least urgent content and can run to
184
+ // many rows, so it gets a way out of the way — but only in the
185
+ // installed viewer, where the heading it folds into exists. A preview
186
+ // draws the same rows headless inside its banner, with nothing to
187
+ // leave behind.
188
+ //
189
+ // Open by default, and a fold lasts only as long as the page: it is
190
+ // module state, deliberately not persisted. Switching to another copy
191
+ // from the table happens in place (`swapToScript`), so the fold
192
+ // survives the hop without any storage; a reload or a second viewer
193
+ // tab starts open again, which is what a view preference that nobody
194
+ // set on purpose should do.
195
+ let panelCollapsed = false;
196
+ // Whether the last paint drew a headed panel at all. A panel that
197
+ // appears after a paint without one is opened regardless of the fold
198
+ // — see the head-building code above.
199
+ let panelHeadDrawn = false;
200
+ function isPanelCollapsed() {
201
+ return panelCollapsed;
202
+ }
203
+ function setPanelCollapsed(next) {
204
+ panelCollapsed = next;
205
+ }
206
+ // The fold control, in the panel's top-right corner. A triangle that
207
+ // points down while the rows are showing, and right once they are
208
+ // folded away.
209
+ function collapseToggle(container, model) {
210
+ const collapsed = isPanelCollapsed();
211
+ const btn = document.createElement('button');
212
+ btn.type = 'button';
213
+ btn.className = 'versions-collapse';
214
+ btn.textContent = collapsed ? '\u25B8' : '\u25BE';
215
+ // One string for both: the glyph names nothing on its own, so the
216
+ // label a screen reader reads and the tooltip a pointer gets have to
217
+ // be the same sentence — same as the row's Remove button.
218
+ const label = collapsed ? 'Show the other versions' : 'Hide the other versions';
219
+ btn.setAttribute('aria-label', label);
220
+ btn.title = label;
221
+ btn.setAttribute('aria-expanded', String(!collapsed));
222
+ // The rows it folds are siblings of the button rather than inside
223
+ // it, so the relationship has to be stated rather than implied. Only
224
+ // when the panel has an id to point at — the viewer's does.
225
+ if (container.id)
226
+ btn.setAttribute('aria-controls', container.id);
227
+ btn.addEventListener('click', () => {
228
+ // Read the flag rather than the captured value, so a path that
229
+ // changes the fold without a repaint can't be toggled backwards.
230
+ setPanelCollapsed(!isPanelCollapsed());
231
+ // Re-render from the model already in hand: a repaint from the
232
+ // store would be a round trip for a change that only concerns what
233
+ // is drawn. It can be a paint behind — one deferred while a
234
+ // `<select>` in the panel had focus — and the deferred paint that
235
+ // follows corrects it.
236
+ renderRelatedVersions(container, model);
237
+ // This button went out with the render that replaced it. Focus the
238
+ // one that took its place, so a keyboard fold can be undone with a
239
+ // second press.
240
+ container.querySelector('.versions-collapse')?.focus();
241
+ });
242
+ return btn;
243
+ }
244
+ // Columns sized to their own content rather than to a guess. Each is
245
+ // as wide as its widest cell (header included) across EVERY section
246
+ // table, so they stay aligned and Location — the column that can
247
+ // always use more room — gets everything left over.
248
+ //
249
+ // Why measure at all: `table-layout: fixed` is what keeps the two
250
+ // tables' columns aligned, and under it the `<colgroup>` decides the
251
+ // width outright, so a column can't shrink to fit its own content. A
252
+ // fixed guess has to cover the worst case (`REFRESH FAILED`, a
253
+ // date-shaped `2026.08.19`), which left a short `1.2.0` stranded a
254
+ // column's breadth from the button beside it.
255
+ //
256
+ // Only the columns whose content has a natural width. Collection and
257
+ // Location are the flexible pair that absorb the remainder, and the
258
+ // two icon columns are already exactly a control wide.
259
+ const FITTED_COLUMNS = ['col-status', 'col-version', 'col-compare', 'col-actions'];
260
+ // Measured by switching the tables to `auto` layout at `max-content`
261
+ // width — the one state where a cell reports what its content actually
262
+ // needs — then restoring. Both happen inside this call with no `await`
263
+ // between, so no frame is ever painted in the measuring state.
264
+ //
265
+ // The widths land as custom properties on the panel rather than as
266
+ // inline widths on the `<col>`s: an inline width would beat the
267
+ // stylesheet's narrow-window rule, which zeroes Version below 640px.
268
+ function fitColumns(container) {
269
+ const tables = [...container.querySelectorAll('.versions-table')];
270
+ if (tables.length === 0)
271
+ return;
272
+ // The `<col>` widths have to come off for the measurement too: under
273
+ // `auto` layout a colgroup width is still a suggestion the browser
274
+ // honours, so leaving them on measures the guess we are trying to
275
+ // replace.
276
+ const cols = [...container.querySelectorAll('.versions-table col')];
277
+ for (const col of cols)
278
+ col.style.width = 'auto';
279
+ // The header's Refresh all is taken out of the measurement entirely,
280
+ // not merely skipped when its own cell is read. It spans Actions and
281
+ // Remove (see `appendHeadings`), and under `auto` layout the browser
282
+ // pushes a spanning cell's excess width down INTO the columns it
283
+ // covers — so a cell we never read still widens Actions to the
284
+ // header button, which is the fit this whole pass exists to avoid.
285
+ // Left in the row but not laid out; `fitSpannedHeader` below gives
286
+ // it back the room it needs afterwards.
287
+ const spanHead = container.querySelector('.versions-table th.col-actions[colspan]');
288
+ if (spanHead)
289
+ spanHead.style.display = 'none';
290
+ for (const table of tables) {
291
+ table.style.tableLayout = 'auto';
292
+ table.style.width = 'max-content';
293
+ }
294
+ const widths = new Map();
295
+ for (const table of tables) {
296
+ for (const cls of FITTED_COLUMNS) {
297
+ for (const cell of table.querySelectorAll(`td.${cls}, th.${cls}`)) {
298
+ widths.set(cls, Math.max(widths.get(cls) ?? 0, cell.getBoundingClientRect().width));
299
+ }
300
+ }
301
+ }
302
+ for (const table of tables) {
303
+ table.style.tableLayout = '';
304
+ table.style.width = '';
305
+ }
306
+ for (const col of cols)
307
+ col.style.width = '';
308
+ if (spanHead)
309
+ spanHead.style.display = '';
310
+ for (const cls of FITTED_COLUMNS) {
311
+ const width = widths.get(cls);
312
+ // A panel measured while hidden reports zero. Leaving the property
313
+ // unset falls back to the stylesheet's worst-case width, which is
314
+ // wrong-but-readable; a zero would collapse the column outright.
315
+ if (width)
316
+ container.style.setProperty(`--w-${cls}`, `${Math.ceil(width)}px`);
317
+ }
318
+ if (spanHead)
319
+ fitSpannedHeader(container, spanHead);
320
+ }
321
+ // Widen Actions if the header button, which covers Actions and Remove,
322
+ // would otherwise hang off the table's right edge — which it will
323
+ // whenever its words are wider than a row's icon button plus a can.
324
+ //
325
+ // Everything here is read AFTER the widths above have landed, in the
326
+ // layout the panel will keep: the button's own width (it is never
327
+ // stretched, so its cell's width doesn't decide it) against the width
328
+ // Remove actually rendered at. Remove is not a fitted column — it is
329
+ // one can wide, from the stylesheet — so there is nothing measured to
330
+ // ask instead. A second layout, still with no `await` between, so no
331
+ // frame is painted in the middle.
332
+ //
333
+ // Either column can be read from any table in the panel: both are
334
+ // container-wide, `--w-col-actions` by construction and Remove because
335
+ // its width is a constant.
336
+ function fitSpannedHeader(container, spanHead) {
337
+ const action = spanHead.firstElementChild;
338
+ const remove = container.querySelector('.versions-table td.col-remove');
339
+ const actions = container.querySelector('.versions-table td.col-actions');
340
+ if (!action || !remove || !actions)
341
+ return;
342
+ const need = Math.ceil(action.getBoundingClientRect().width - remove.getBoundingClientRect().width);
343
+ if (need > actions.getBoundingClientRect().width) {
344
+ container.style.setProperty('--w-col-actions', `${need}px`);
345
+ }
346
+ }
347
+ // One call, two operations as far as the user is concerned — the same
348
+ // `updateScript(id, 'manual', 'force')`, named for the page it appears
349
+ // on. The installed viewer's own control says Refresh, so the panel's
350
+ // rows do too; a preview is about bringing a copy up to the file
351
+ // already on screen, so there they say Update. A third name would read
352
+ // as a third operation.
353
+ //
354
+ // The two also LOOK different, which is `actionsCell`'s business:
355
+ // Refresh is the arrow glyph, Update stays a word. A preview fetches
356
+ // nothing — the file is already downloaded — so the refresh arrow
357
+ // would be a promise of a round trip that never happens.
358
+ function refetchVerb(mode) {
359
+ return mode === 'installed' ? 'Refresh' : 'Update';
360
+ }
361
+ // Which rows get the refetch button, and so which ones "…all" covers.
362
+ //
363
+ // Installed: every row. The refetch is per row — each copy is re-read
364
+ // from its own collection — so a different-URL copy refreshes as well
365
+ // as a same-URL one, and a row wearing a `modified` chip always has
366
+ // the button that acts on it. (The panel files its sections by the
367
+ // script the page was opened with, so after an in-page switch the copy
368
+ // on screen may well sit in the different-URL section.)
369
+ //
370
+ // Preview: same-location rows only. The call is the same per-row
371
+ // refetch, but a preview is about the URL on screen, and Update there
372
+ // means "bring this copy up to what you are looking at" — which a
373
+ // different-URL row, refetching its own upstream, would not be.
374
+ function updatableRows(model) {
375
+ return model.mode === 'preview' ? model.sameLocation : allRelatedRows(model);
376
+ }
377
+ function rowUpdatable(rel, mode) {
378
+ return mode === 'preview' ? rel.sameLocation : true;
379
+ }
380
+ // A section heading with an optional parenthesized gloss. The gloss
381
+ // says which of the two matches produced the rows below, so it has to
382
+ // be visible — but it's an aside to the heading, not part of it, and
383
+ // carrying the heading's weight would make the two sections read as
384
+ // equally shouty. It rides inside the `<h3>` (it belongs to the
385
+ // heading semantically) with the emphasis turned back off.
386
+ function sectionHeading(text, gloss) {
387
+ const h3 = document.createElement('h3');
388
+ h3.className = 'versions-section-heading';
389
+ h3.appendChild(document.createTextNode(text));
390
+ if (gloss) {
391
+ const span = document.createElement('span');
392
+ span.className = 'versions-heading-gloss';
393
+ span.textContent = ` (${gloss})`;
394
+ h3.appendChild(span);
395
+ }
396
+ return h3;
397
+ }
398
+ // `headerAction` rides in the actions column of the header row, where
399
+ // it lines up under nothing and over every per-row button — the
400
+ // natural home for a control that acts on the whole section.
401
+ //
402
+ // Each section is a separate `<table>`, so their columns would size
403
+ // independently and the panel would read as unrelated grids. A
404
+ // `<colgroup>` plus `table-layout: fixed` (in the stylesheet) pins
405
+ // every column to the same width in all of them.
406
+ //
407
+ // `headings: 'hidden'` keeps the heading row but takes it off screen —
408
+ // for a section drawn under one whose labels the reader has already
409
+ // seen. Off screen rather than dropped: the visual labelling carries
410
+ // over between the tables, but the ACCESSIBILITY tree has no link
411
+ // between them, and a row of cells with no `<th>` in their own table
412
+ // announces as four unlabelled values.
413
+ function makeTable(opts = {}) {
414
+ const table = document.createElement('table');
415
+ table.className = 'versions-table';
416
+ const cols = document.createElement('colgroup');
417
+ for (const cls of [
418
+ 'col-enabled',
419
+ 'col-collection',
420
+ 'col-location',
421
+ 'col-status',
422
+ 'col-version',
423
+ 'col-compare',
424
+ 'col-actions',
425
+ 'col-remove',
426
+ ]) {
427
+ const col = document.createElement('col');
428
+ col.className = cls;
429
+ cols.appendChild(col);
430
+ }
431
+ table.appendChild(cols);
432
+ appendHeadings(table, opts.headerAction, opts.headings === 'hidden');
433
+ table.createTBody();
434
+ return table;
435
+ }
436
+ function appendHeadings(table, headerAction, hidden) {
437
+ const thead = table.createTHead();
438
+ if (hidden)
439
+ thead.className = 'visually-hidden';
440
+ const head = thead.insertRow();
441
+ for (const [label, cls] of [
442
+ ['On', 'col-enabled'],
443
+ ['Collection', 'col-collection'],
444
+ ['Location', 'col-location'],
445
+ ['Status', 'col-status'],
446
+ ['Version', 'col-version'],
447
+ ['Compare', 'col-compare'],
448
+ ['', 'col-actions'],
449
+ ['', 'col-remove'],
450
+ ]) {
451
+ // The header action (Refresh all) is the table's own control, not
452
+ // the actions column's heading, so it spans that column and the
453
+ // removal one and sits flush with the table's right edge. Without
454
+ // the span it stops a can's width short of it, which reads as a
455
+ // button that missed its alignment rather than as a heading.
456
+ if (cls === 'col-remove' && headerAction)
457
+ continue;
458
+ const th = document.createElement('th');
459
+ th.className = cls;
460
+ if (cls === 'col-actions' && headerAction) {
461
+ // No `scope`: this cell holds the table's own control, not a
462
+ // heading for anything under it. (`colgroup` would be the value
463
+ // if it did head both columns.)
464
+ th.colSpan = 2;
465
+ th.appendChild(headerAction);
466
+ }
467
+ else {
468
+ th.scope = 'col';
469
+ th.textContent = label;
470
+ }
471
+ head.appendChild(th);
472
+ }
473
+ }
474
+ function versionRow(rel, model, verb, allRows) {
475
+ const { script } = rel;
476
+ const { actions, mode } = model;
477
+ const status = model.status?.get(script.id);
478
+ // This row is a local copy whose file the page can delete — see
479
+ // `RelatedVersionsModel.canDeleteFile`.
480
+ const deletesFile = model.canDeleteFile?.(script, rel.collection) ?? false;
481
+ const isCurrent = script.id === actions.currentScriptId;
482
+ const tr = document.createElement('tr');
483
+ if (isCurrent) {
484
+ tr.className = 'is-current';
485
+ // The `(this page)` marker is a run of text in the Location cell;
486
+ // to a screen reader it reads as more path, not as "you are here".
487
+ // This says it as state.
488
+ tr.setAttribute('aria-current', 'true');
489
+ // Focusable even though activating it does nothing — it is already
490
+ // the copy on screen. Two things need it to hold focus: Up/Down
491
+ // walk the table row by row, and a row that cannot take focus
492
+ // would be a hole in that walk; and it is where focus lands after
493
+ // a selection, so the user stays where they were standing.
494
+ //
495
+ // No `role`/`aria-label` to go with the tab stop, unlike the
496
+ // navigable rows: this one does nothing when activated, so it
497
+ // stays an ordinary `row` and keeps its cells' column
498
+ // associations. `aria-current` above is what names it.
499
+ if (mode === 'installed')
500
+ tr.tabIndex = 0;
501
+ }
502
+ else if (mode === 'installed') {
503
+ // The whole row selects this copy — the viewer picks the click up
504
+ // off the row (see its `navigateToScript`). Marked here rather than
505
+ // assumed there so the pointer cursor and the row that acts are the
506
+ // same set, and so a preview — which has no script to swap and
507
+ // navigates for real — gets neither.
508
+ tr.className = 'is-navigable';
509
+ // The row IS the control, so it takes the tab stop its Location
510
+ // cell gave up when that stopped being a link: same number of stops
511
+ // through the table as before, with Enter / Space on the row
512
+ // handled by the viewer's keydown listener.
513
+ tr.tabIndex = 0;
514
+ // And it has to SAY so. A focusable element that does something
515
+ // nameless is worse than the link it replaced: `role` is what makes
516
+ // Enter an announced affordance, and the label is what says where
517
+ // it goes — the cells alone read as a row of values.
518
+ //
519
+ // The cost, stated plainly: this row stops being a `row` to a
520
+ // screen reader, so its cells lose their column associations. Only
521
+ // the navigable rows pay it — the current row and every preview row
522
+ // keep ordinary table semantics — and a row whose whole purpose is
523
+ // to be activated is better announced as the link it behaves like.
524
+ tr.setAttribute('role', 'link');
525
+ tr.setAttribute('aria-label', `Open ${displayLocation(script.relativePath)}`);
526
+ }
527
+ tr.dataset.scriptId = script.id;
528
+ tr.appendChild(enabledCell(rel, actions, allRows));
529
+ tr.appendChild(collectionCell(rel));
530
+ tr.appendChild(locationCell(rel, isCurrent, mode));
531
+ tr.appendChild(statusCell(script.id, status));
532
+ tr.appendChild(versionCell(script));
533
+ tr.appendChild(contentCell(isCurrent ? undefined : script, model));
534
+ tr.appendChild(actionsCell(rel, actions, isCurrent, verb, mode));
535
+ tr.appendChild(removeCell(rel, actions, deletesFile));
536
+ return tr;
537
+ }
538
+ // The per-script toggle, live. This is the panel's whole reason for
539
+ // carrying a checkbox: with several copies of one script installed,
540
+ // deciding which one runs is the action the user came here for, and
541
+ // making them visit each script's own page to do it defeats the
542
+ // point.
543
+ //
544
+ // Enabling is EXCLUSIVE: two enabled copies of one script both
545
+ // register and both run, which is almost never what the user wants, so
546
+ // turning one on turns the rest off. Turning one off is not — that
547
+ // leaves none running, which is a perfectly ordinary thing to want and
548
+ // has no other copy to promote. Ctrl/Cmd-click opts out and toggles
549
+ // just the one box, for the rare case where two copies really are
550
+ // meant to run (say, two `@match`-disjoint edits of one script).
551
+ function enabledCell(rel, actions, allRows) {
552
+ const { script } = rel;
553
+ const td = cell('col-enabled');
554
+ const label = document.createElement('label');
555
+ label.className = 'toggle';
556
+ const box = document.createElement('input');
557
+ box.type = 'checkbox';
558
+ box.checked = isScriptEnabled(script);
559
+ // A disabled collection disables every script under it, so a row that
560
+ // showed only `script.enabled` would read as running when it isn't —
561
+ // in a panel whose whole point is "which copy runs". Same treatment
562
+ // as the viewer's own toggle.
563
+ const collectionOff = rel.collection ? !isCollectionEnabled(rel.collection) : false;
564
+ if (collectionOff) {
565
+ box.disabled = true;
566
+ label.classList.add('toggle-disabled');
567
+ }
568
+ // The visible tooltip, same wording as the controls-row toggle. The
569
+ // hint is only worth showing where the sweep can actually fire: with
570
+ // nothing else running there are no "others" to keep.
571
+ label.title = collectionOff
572
+ ? COLLECTION_OFF_TITLE
573
+ : enableTitle(otherActiveCopies(allRows, script.id).length > 0);
574
+ // The screen-reader name carries what the tooltip drops. The checkbox
575
+ // is the only content of its cell, so it has no accessible name from
576
+ // markup at all — and five rows announcing "Enable this script" would
577
+ // be indistinguishable, where a sighted user just reads across to the
578
+ // Collection cell.
579
+ const what = rel.collection ? effectiveDisplayName(rel.collection) : 'this copy';
580
+ box.setAttribute('aria-label', collectionOff
581
+ ? `${what}. The collection is disabled, so this copy does not run.`
582
+ : `${label.title}, in ${what}`);
583
+ const takeSolo = watchSoloModifier(box);
584
+ box.onchange = () => {
585
+ const wanted = box.checked;
586
+ // Only enabling is exclusive, and only without the opt-out
587
+ // modifier.
588
+ const solo = takeSolo();
589
+ const changes = enableChanges(script.id, wanted, solo ? [] : allRows);
590
+ box.disabled = true;
591
+ void actions
592
+ .setEnabled(changes, wanted && !solo ? allRows.map((r) => r.script.id) : null)
593
+ // The store refused the write, so the box must not keep a value
594
+ // that never landed. Rarely the thing the user actually sees: the
595
+ // caller repaints from stored state on both paths, which replaces
596
+ // this node outright. This is the backstop for a caller that
597
+ // doesn't — the panel's contract is that a rejection means
598
+ // nothing landed, and a box left showing an intent that was
599
+ // refused is a lie.
600
+ .catch(() => {
601
+ box.checked = !wanted;
602
+ })
603
+ // No re-describe: the wording no longer depends on the box's own
604
+ // state, only on what else is installed — which a repaint is
605
+ // what re-reads.
606
+ .finally(() => {
607
+ box.disabled = false;
608
+ });
609
+ };
610
+ label.appendChild(box);
611
+ td.appendChild(label);
612
+ return td;
613
+ }
614
+ // Collection name links to the collections page, scrolled to that
615
+ // collection's section — the same `#collection-<id>` anchor the
616
+ // viewer's Collection row uses, so both routes land in the same place.
617
+ //
618
+ // Bubble first, then the name: same order as the details panel's
619
+ // Collection row and the collections page's own headings. The bubble is
620
+ // a fixed-width tag and the name is the variable part, so leading with
621
+ // it also keeps a column of rows aligned down the left edge.
622
+ function collectionCell(rel) {
623
+ const td = cell('col-collection');
624
+ if (!rel.collection) {
625
+ td.appendChild(document.createTextNode('(unknown collection)'));
626
+ return td;
627
+ }
628
+ td.appendChild(makeCollectionTypeBubble(rel.collection.path));
629
+ td.appendChild(document.createTextNode(' '));
630
+ td.appendChild(collectionNameLink(rel.collection));
631
+ return td;
632
+ }
633
+ // Back-link to a collection's section on the collections page. One
634
+ // helper so every row carries the same class, href, and tooltip — they
635
+ // drifted apart when this was written twice.
636
+ function collectionNameLink(collection) {
637
+ const a = document.createElement('a');
638
+ a.className = 'versions-collection-name';
639
+ // Opens the collections page's named tab beside this one — see
640
+ // src/extension/page-links.ts — rather than navigating the viewer away.
641
+ wireNamedTabLink(a, 'collections', collectionAnchorId(collection.id));
642
+ a.title = 'Show this collection on the collections page';
643
+ appendWithSlashBreaks(a, effectiveDisplayName(collection));
644
+ return a;
645
+ }
646
+ // The copy's stored location. Display decodes, the href — where there
647
+ // still is one — does not; see [docs/names-and-paths.md].
648
+ //
649
+ // A link only in a PREVIEW, where clicking it is the only way to reach
650
+ // that copy. On the details page the whole row already opens the copy,
651
+ // so a link inside it would offer the same destination twice and
652
+ // underline one cell for no reason.
653
+ function locationCell(rel, isCurrent, mode) {
654
+ const td = cell('col-location');
655
+ const text = displayLocation(rel.script.relativePath);
656
+ const title = locationTitle(rel, text);
657
+ // The element carrying the path is tagged with the file it names, so
658
+ // a deleted or changed file gets its chip (see `markFileChanges`).
659
+ const tagFile = (el) => {
660
+ const tag = rel.collection && scriptFileTag(rel.script, rel.collection.path);
661
+ if (tag)
662
+ tagLocalFile(el, tag);
663
+ };
664
+ if (isCurrent || mode === 'installed') {
665
+ const span = document.createElement('span');
666
+ if (isCurrent)
667
+ span.className = 'versions-current-marker';
668
+ if (title)
669
+ span.title = title;
670
+ appendWithSlashBreaks(span, text);
671
+ tagFile(span);
672
+ if (isCurrent) {
673
+ const here = document.createElement('strong');
674
+ here.className = 'versions-this-page';
675
+ here.textContent = '(this page)';
676
+ span.appendChild(document.createTextNode(' '));
677
+ span.appendChild(here);
678
+ }
679
+ td.appendChild(span);
680
+ return td;
681
+ }
682
+ const a = document.createElement('a');
683
+ a.href = viewerUrl(rel.script.id);
684
+ if (title)
685
+ a.title = title;
686
+ appendWithSlashBreaks(a, text);
687
+ tagFile(a);
688
+ td.appendChild(a);
689
+ return td;
690
+ }
691
+ // What hovering the location adds to what the cell already shows: the
692
+ // FULL location, resolved and unabridged.
693
+ //
694
+ // The cell shows the stored spelling, which is only ever part of the
695
+ // answer:
696
+ // - a directory or manifest collection stores the path under its
697
+ // root, so `gm-menu/gm-menu.user.js` in the cell is
698
+ // `/home/…/tests/examples/gm-menu/gm-menu.user.js` on disk — and
699
+ // the root is over in the Collection cell, not spliced onto it;
700
+ // - a github row stores the raw URL and resolves to the `blob` page
701
+ // URL a human would open;
702
+ // - a percent-encoded URL displays decoded, so the tooltip is where
703
+ // the literal form it was installed from survives.
704
+ //
705
+ // Empty — and so no tooltip at all — when that resolves to exactly the
706
+ // text already on screen, which is the common `https://…/foo.user.js`
707
+ // case. A tooltip repeating its own cell is noise.
708
+ function locationTitle(rel, shown) {
709
+ const full = rel.collection
710
+ ? scriptAbsolutePath(rel.collection.path, rel.script.relativePath)
711
+ : rel.script.relativePath;
712
+ return full === shown ? '' : full;
713
+ }
714
+ // Status, linking to that copy's Log tab. Every row links, including
715
+ // OK ones, and the colour rather than the underline is what carries
716
+ // the meaning — see [docs/script-identity.md] for both arguments.
717
+ //
718
+ // The current row's href resolves to this page's own URL plus `#Log`,
719
+ // which is a same-document fragment navigation: it switches tabs via
720
+ // the ordinary hashchange listener rather than reloading. That holds
721
+ // only while the query string matches byte-for-byte — a viewer URL
722
+ // that grew a second parameter would silently turn this into a page
723
+ // load. Another row's href goes to another script, which the installed
724
+ // viewer handles in place (`navigateToScript`), landing on that copy's
725
+ // Log tab.
726
+ function statusCell(scriptId, status) {
727
+ const td = cell('col-status');
728
+ if (!status) {
729
+ // Nothing was passed for this row, so say nothing rather than
730
+ // guess: an unlabelled cell is honest, a green OK would not be.
731
+ td.textContent = '—';
732
+ return td;
733
+ }
734
+ const a = document.createElement('a');
735
+ // The `status-*` colour classes only — NOT the controls row's
736
+ // `.meta-status`, which forces `color: inherit`.
737
+ a.className = `status-${status.status}`;
738
+ a.href = viewerUrl(scriptId, 'log');
739
+ a.textContent = status.label;
740
+ // Titled only when the verdict has something to add, exactly as the
741
+ // details panel's own Status row does with the same view — a cell
742
+ // that carried a tooltip the identical verdict lacks would read as a
743
+ // different measurement.
744
+ if (status.title)
745
+ a.title = status.title;
746
+ td.appendChild(a);
747
+ return td;
748
+ }
749
+ function versionCell(script) {
750
+ const v = script.header ? getValue(script.header, 'version') : undefined;
751
+ return versionCellFor(v, 'This copy declares no @version');
752
+ }
753
+ function versionCellFor(version, noneTitle) {
754
+ const td = cell('col-version');
755
+ td.textContent = version ?? '—';
756
+ if (!version)
757
+ td.title = noneTitle;
758
+ return td;
759
+ }
760
+ // Whether this row's loaded content — body and `@require`s, see
761
+ // `contentFingerprint` — matches the copy the panel compares against:
762
+ // the script on screen, or the download a preview is showing.
763
+ //
764
+ // The reference row itself (`script` undefined) is labelled rather
765
+ // than left blank: a copy is neither the same as nor different from
766
+ // itself, and a blank there read as a missing value rather than as the
767
+ // row the others are measured against. `BASE` on the viewer; `NEW` on
768
+ // a preview, where the reference is the download and the installed
769
+ // rows are what it would replace. An em dash when either side has no content to
770
+ // compare (see `contentFingerprint`: never loaded, or `@require`s it
771
+ // does not hold) — for the same reason the Status cell shows one:
772
+ // absent is not a claim.
773
+ //
774
+ // Compared against what is LOADED, not what is on disk or at the URL:
775
+ // a local file edited since it was read still says Same until it is
776
+ // refreshed, and the Location cell's `modified` chip is what says so.
777
+ function contentCell(script, model) {
778
+ const { mode } = model;
779
+ const td = cell('col-compare');
780
+ if (!script) {
781
+ const span = document.createElement('span');
782
+ span.className = 'content-base';
783
+ span.textContent = mode === 'preview' ? 'NEW' : 'BASE';
784
+ span.title = mode === 'preview'
785
+ ? 'The script being previewed, which the installed copies are compared against'
786
+ : 'The copy on screen, which the other rows are compared against';
787
+ td.appendChild(span);
788
+ return td;
789
+ }
790
+ const own = contentFingerprint(script);
791
+ if (own === undefined || model.content === undefined) {
792
+ td.textContent = '—';
793
+ return td;
794
+ }
795
+ const other = mode === 'preview' ? 'the script being previewed' : 'the copy on screen';
796
+ if (own === model.content) {
797
+ const span = document.createElement('span');
798
+ span.className = 'content-same';
799
+ span.textContent = 'SAME';
800
+ span.title = `This copy\u2019s code and @require files match ${other}`;
801
+ td.appendChild(span);
802
+ return td;
803
+ }
804
+ // A copy that differs gets a DIFF button, which opens the compare
805
+ // page in a new tab: the panel is where the user is working, and the
806
+ // compare page is a look at one copy from it. A button rather than a
807
+ // coloured word, because a word in a column of verdicts does not read
808
+ // as something to click. Without a page to link to, the verdict is
809
+ // the plain word.
810
+ const href = model.compareHref?.(script);
811
+ if (!href) {
812
+ const span = document.createElement('span');
813
+ span.className = 'content-different';
814
+ span.textContent = 'DIFFERENT';
815
+ span.title = `This copy\u2019s code or @require files differ from ${other}`;
816
+ td.appendChild(span);
817
+ return td;
818
+ }
819
+ const btn = document.createElement('button');
820
+ btn.type = 'button';
821
+ btn.className = 'content-diff';
822
+ btn.textContent = 'DIFF';
823
+ btn.title = `This copy\u2019s code or @require files differ from ${other}. Click to show the differences.`;
824
+ btn.dataset.href = href;
825
+ btn.addEventListener('click', () => { void openExtensionTab(href); });
826
+ td.appendChild(btn);
827
+ return td;
828
+ }
829
+ function actionsCell(rel, actions, isCurrent, verb, mode) {
830
+ const td = cell('col-actions');
831
+ // Which rows get the button is `updatableRows`'s call. The current
832
+ // script's own row is NOT excluded: see the call site.
833
+ if (rowUpdatable(rel, mode) && actions.update) {
834
+ const title = isCurrent
835
+ ? `${verb} this script from its collection`
836
+ : `${verb} this copy from its collection`;
837
+ // Two different operations, so two different-looking buttons —
838
+ // this is the one place the panel does NOT reuse the refresh arrow
839
+ // (see `refetchVerb`).
840
+ //
841
+ // Installed: the arrow, the same refetch this page's own Refresh
842
+ // does — go and see whether the upstream moved.
843
+ //
844
+ // Preview: a word. Nothing is fetched; the file is already
845
+ // downloaded and sitting on screen, and the button decides whether
846
+ // to write it over this copy. An arrow would promise a round trip
847
+ // that isn't going to happen, and would make the row look like the
848
+ // installed case it isn't.
849
+ // Bold in a preview: with the file already downloaded and copies
850
+ // listed here, updating one of them is the likelier thing to want,
851
+ // so this is the row's recommended action and the header's Install
852
+ // gives up its emphasis to it (`showInstallCallout`).
853
+ td.appendChild(mode === 'preview'
854
+ ? button(verb, title, () => void actions.update?.([rel.script.id]), true)
855
+ : glyphButton('#sm-refresh', title, () => void actions.update?.([rel.script.id])));
856
+ }
857
+ return td;
858
+ }
859
+ // The trash can, in its own column to the right of the refetch button —
860
+ // the same glyph and hover treatment as the collections page's
861
+ // per-script remove (`.remove-script`), so the two read as one control
862
+ // in two places. Its own class, because the panel sits on a different
863
+ // ground and the borders have to match that; and, unlike the
864
+ // collections page, it appears disabled rather than hidden where it
865
+ // can't act.
866
+ //
867
+ // Live only for scripts in a container collection (the "Scripts from
868
+ // local disk" / "…from web" / "…from GitHub" buckets), which the user
869
+ // added one at a time and can take back out the same way. A script that
870
+ // arrived as part of a directory, manifest, or repo collection is not
871
+ // independently removable: the next refresh of that collection would
872
+ // scan it straight back in. Rather than hide the button on those rows —
873
+ // leaving a ragged column and no clue why some rows can be removed — it
874
+ // is shown disabled, with a tooltip that says where the removal
875
+ // actually lives. What to call a collection that holds many scripts,
876
+ // when telling the user their copy can't be removed on its own.
877
+ //
878
+ // "Collection" is the name for the whole family, but it says nothing
879
+ // about how this one was added. "Directory" and "manifest" are the words
880
+ // the collections page already uses for that, so they are the ones a
881
+ // user can act on. A GitHub repo or `/tree/` collection is a directory
882
+ // of scripts too, and reads that way.
883
+ function collectionNoun(collection) {
884
+ return collection && collectionHasManifest(collection.path) ? 'manifest' : 'directory';
885
+ }
886
+ function removeCell(rel, actions, deletesFile) {
887
+ const td = cell('col-remove');
888
+ if (!actions.remove)
889
+ return td;
890
+ // A row whose collection dangles is an orphan — the thing a user
891
+ // would most want to clear out, and nothing will scan it back in — so
892
+ // it gets the live button rather than a tooltip about a collection
893
+ // that isn't there.
894
+ const removable = deletesFile
895
+ || (rel.collection ? isContainerCollection(rel.collection.path) : true);
896
+ const b = document.createElement('button');
897
+ b.type = 'button';
898
+ b.className = 'versions-remove'; // `.icon-btn` is added by setDeleteIcon
899
+ // The badged can only on the row whose button takes the file with
900
+ // it; every other copy here is just a record.
901
+ setDeleteIcon(b, deletesFile ? 'file' : 'record');
902
+ // A local copy's button says what makes it different — in the glyph
903
+ // above and here again in words: it takes the file off the disk as
904
+ // well, which is why this row has one at all.
905
+ const title = deletesFile
906
+ ? 'Delete local script file'
907
+ : removable
908
+ ? 'Remove this copy of the script'
909
+ : `Script from ${collectionNoun(rel.collection)} collection `
910
+ + 'can only be removed by removing the collection';
911
+ b.title = title;
912
+ b.setAttribute('aria-label', title);
913
+ if (removable) {
914
+ b.onclick = () => {
915
+ b.disabled = true;
916
+ void actions.remove?.(rel.script.id);
917
+ };
918
+ }
919
+ else {
920
+ b.disabled = true;
921
+ // Also on the cell: a disabled button is out of the tab order, so
922
+ // hovering anywhere in the column is the one way most users will
923
+ // ever meet the explanation.
924
+ td.title = title;
925
+ }
926
+ td.appendChild(b);
927
+ return td;
928
+ }
929
+ // The offer to install your own copy of the script you are looking at,
930
+ // somewhere of your choosing — the whole of the panel's third section.
931
+ //
932
+ // Still a row in a table like the sections above it, so the Location
933
+ // and Version of the copy you would be adding line up under the copies
934
+ // you already have. That comparison is the reason it is here rather
935
+ // than in a callout of its own.
936
+ //
937
+ // What it does NOT keep is the columns left of Location: a copy that
938
+ // does not exist has no enable box, and its collection is the question
939
+ // rather than an answer. Those two cells become one, holding the same
940
+ // question the callout asks when nothing is installed anywhere, in the
941
+ // same shape: Install, "into", dropdown, on one line. Here rather than
942
+ // in the actions column, which is a whole table's width away from the
943
+ // control that decides what the button would do.
944
+ function installRow(options, model) {
945
+ const actions = model.actions;
946
+ const tr = document.createElement('tr');
947
+ tr.className = 'is-offer';
948
+ const controls = cell('col-install');
949
+ controls.colSpan = 2;
950
+ const select = buildInstallSelect(options, { onChange: () => sync() });
951
+ select.id = OFFER_SELECT_ID;
952
+ // The button, the word, and the dropdown read as one sentence —
953
+ // "Install into <collection>" — with the button as its verb. The
954
+ // label is the connector in that sentence, so it is not a heading
955
+ // and is not bold; the select keeps the whole phrase as its
956
+ // accessible name, since "into" alone names nothing.
957
+ const label = document.createElement('label');
958
+ label.htmlFor = OFFER_SELECT_ID;
959
+ label.textContent = 'into';
960
+ select.setAttribute('aria-label', 'Install into');
961
+ const b = button('Install', 'Install a copy of this script into the chosen collection', () => {
962
+ void actions.install?.(b, selectedInstallChoice(select, options));
963
+ }, true);
964
+ const pick = document.createElement('div');
965
+ pick.className = 'install-offer-pick';
966
+ pick.append(b, label, select);
967
+ // The pre-selected entry can be one that already holds this script
968
+ // (the default stays first regardless); Install then waits for a
969
+ // deliberate pick, and this line says why it is off.
970
+ //
971
+ // Under the line rather than beside the button: the button leads a
972
+ // sentence, and a note spliced into the middle of one breaks the
973
+ // reading.
974
+ const note = document.createElement('div');
975
+ note.className = 'install-offer-note';
976
+ const sync = () => {
977
+ const blocked = installChoiceBlocked(select, options);
978
+ b.disabled = blocked;
979
+ note.textContent = blocked ? CHOOSE_ANOTHER_COLLECTION : '';
980
+ };
981
+ sync();
982
+ controls.append(pick, note);
983
+ tr.appendChild(controls);
984
+ tr.appendChild(offerLocationCell(model.location));
985
+ // No stored row, so no status: this file has never been loaded,
986
+ // registered, or logged against. An em dash, as a row whose status
987
+ // lookup failed also uses.
988
+ const stat = cell('col-status');
989
+ stat.className += ' versions-offer-text';
990
+ stat.textContent = '—';
991
+ tr.appendChild(stat);
992
+ // Not a copy — an offer — so the no-version tooltip has to be about
993
+ // the file being previewed, not about a row that doesn't exist.
994
+ tr.appendChild(versionCellFor(model.version, 'This script declares no @version'));
995
+ // The offer IS the download the other rows are compared against, so
996
+ // it is the base row here — same as the current row on the viewer.
997
+ tr.appendChild(contentCell(undefined, model));
998
+ // Nothing to refetch and nothing to remove: the copy does not exist
999
+ // yet. Empty cells rather than disabled controls, which would imply
1000
+ // one does.
1001
+ tr.appendChild(cell('col-actions'));
1002
+ tr.appendChild(cell('col-remove'));
1003
+ return tr;
1004
+ }
1005
+ // Where the copy this row offers would load from — the location the
1006
+ // whole panel was computed for, in the same column as the installed
1007
+ // copies' own paths.
1008
+ //
1009
+ // The ABSOLUTE location, which is not always the spelling above it: a
1010
+ // row inside a directory or manifest collection stores its path under
1011
+ // that collection's root and prints the short form. Absolute anyway,
1012
+ // because it is what this row's install would store — only a scripts
1013
+ // collection can take an individually installed script, and those keep
1014
+ // the location whole. The column lines the two up; it does not claim
1015
+ // they are spelled alike.
1016
+ //
1017
+ // Never a link: it is the page you are already on. Tooltipped with the
1018
+ // undecoded spelling when percent-encoding makes one differ from the
1019
+ // other — `locationTitle`'s decoding case, without its resolve-the-
1020
+ // collection-root case, which a location that is already absolute
1021
+ // cannot be in.
1022
+ function offerLocationCell(location) {
1023
+ const td = cell('col-location');
1024
+ const text = displayLocation(location);
1025
+ if (text !== location)
1026
+ td.title = location;
1027
+ appendWithSlashBreaks(td, text);
1028
+ return td;
1029
+ }
1030
+ // The offer section's dropdown, which is a labelled control and so
1031
+ // needs an id. Distinct from the callout's `install-target`: the callout is
1032
+ // hidden rather than emptied when the row takes over, so a shared id
1033
+ // would put two of them in the document.
1034
+ const OFFER_SELECT_ID = 'install-target-offer';
1035
+ // The `<option>` value that means "New collection…".
1036
+ const NEW_COLLECTION_VALUE = 'new';
1037
+ // Why the offer section's Install is off: the pre-selected default is a
1038
+ // collection that already holds this script. Only this row says it in
1039
+ // words — the move dialog's equivalent state is the *(current)* entry,
1040
+ // which is labelled rather than explained.
1041
+ const CHOOSE_ANOTHER_COLLECTION = 'Already installed there. Choose another collection.';
1042
+ // Whether the dropdown's current answer is an entry neither Install
1043
+ // nor Move can act on: one that already holds this script, or — for a
1044
+ // move — the collection it is in now. One predicate, so the two
1045
+ // controls can't disagree about when their button is live.
1046
+ export function installChoiceBlocked(select, options) {
1047
+ const chosen = selectedInstallChoice(select, options);
1048
+ return 'option' in chosen && (chosen.option.installed || chosen.option.isCurrent);
1049
+ }
1050
+ // The "Install into" dropdown, in three groups separated by rules:
1051
+ // the built-in default for this script's scheme (existing, or "(new)"
1052
+ // — created by the install); every other scripts collection in page
1053
+ // order; "New collection…". Entries that already hold this script are
1054
+ // disabled and marked. The first entry is always pre-selected, even
1055
+ // when it is disabled — installing a second copy is never the one-click
1056
+ // path.
1057
+ //
1058
+ // Every entry, "New collection…" included, only *selects*. Nothing
1059
+ // happens until Install is clicked.
1060
+ //
1061
+ // `<hr>` inside `<select>` is native in Chrome ≥ 119, well under the
1062
+ // extension's floor.
1063
+ export function buildInstallSelect(options, config) {
1064
+ const select = document.createElement('select');
1065
+ select.className = 'install-target';
1066
+ // Opens the list on the mouse release, so the click that opens it
1067
+ // can't also pick from it — see select-open-guard.ts.
1068
+ guardSelectOpeningClick(select);
1069
+ select.title = config.title ?? 'Which collection to install this script into';
1070
+ // Which entry opens selected: the one the script is already in when
1071
+ // there is one (the move dropdown), else the scheme default that
1072
+ // leads the list. Either way the opening choice is somewhere the
1073
+ // script already lives, so acting on it takes a deliberate pick.
1074
+ const preselect = Math.max(options.findIndex((o) => o.isCurrent), 0);
1075
+ options.forEach((o, i) => {
1076
+ if (i === 1)
1077
+ select.appendChild(document.createElement('hr'));
1078
+ // At most one suffix, in this order: "current" is where the script
1079
+ // is now; "already installed" is why another entry can't be picked;
1080
+ // "new" says the row doesn't exist yet; "disabled" warns about what
1081
+ // happens after. The first three can't co-occur with "disabled"
1082
+ // anyway — a default that doesn't exist is created enabled.
1083
+ const text = o.isCurrent ? `${o.label} (current)`
1084
+ : o.installed ? `${o.label} (already installed)`
1085
+ : !o.collection ? `${o.label} (new)`
1086
+ : o.collectionOff ? `${o.label} (disabled)`
1087
+ : o.label;
1088
+ const opt = new Option(text, String(i));
1089
+ opt.disabled = o.installed || o.isCurrent;
1090
+ if (i === preselect)
1091
+ opt.selected = true;
1092
+ select.appendChild(opt);
1093
+ });
1094
+ select.appendChild(document.createElement('hr'));
1095
+ select.appendChild(new Option('New collection…', NEW_COLLECTION_VALUE));
1096
+ select.onchange = () => config.onChange?.();
1097
+ return select;
1098
+ }
1099
+ // What a `buildInstallSelect` dropdown currently answers: one of the
1100
+ // listed containers, or "make me a new one". Falls back to the first
1101
+ // entry, since the select holds nothing else.
1102
+ //
1103
+ // The empty-list fallback is a guard, not a reachable state:
1104
+ // `installOptionsFor` always returns at least the scheme default. It
1105
+ // exists because every caller dereferences `choice.option`, so a list
1106
+ // that somehow came back empty must not hand them `undefined`.
1107
+ export function selectedInstallChoice(select, options) {
1108
+ if (select.value === NEW_COLLECTION_VALUE)
1109
+ return { newCollection: true };
1110
+ const option = options[Number(select.value)] ?? options[0];
1111
+ return option ? { option } : { newCollection: true };
1112
+ }
1113
+ function cell(cls) {
1114
+ const td = document.createElement('td');
1115
+ td.className = cls;
1116
+ return td;
1117
+ }
1118
+ // The bare button both builders below start from, so `type` and the
1119
+ // click wiring can't drift between them.
1120
+ function makeButton(title, onClick) {
1121
+ const b = document.createElement('button');
1122
+ b.type = 'button';
1123
+ b.title = title;
1124
+ b.onclick = onClick;
1125
+ return b;
1126
+ }
1127
+ // The glyph-bearing sibling of `button`. `text` is left out for an
1128
+ // icon-only button, where `title` is the whole label — the row buttons
1129
+ // — and supplied where a word carries something the arrow can't, as
1130
+ // "all" does in the header action.
1131
+ function glyphButton(symbolId, title, onClick, text) {
1132
+ const b = makeButton(title, onClick);
1133
+ // Sets the title again, and the `aria-label` an icon-only button
1134
+ // can't do without.
1135
+ setGlyphButton(b, symbolId, title, text === undefined ? {} : { text });
1136
+ return b;
1137
+ }
1138
+ function button(label, title, onClick, primary = false) {
1139
+ const b = makeButton(title, onClick);
1140
+ b.textContent = label;
1141
+ if (primary)
1142
+ b.className = 'primary';
1143
+ return b;
1144
+ }