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,2905 @@
1
+ // Collections page controller. Renders one section per registered
2
+ // collection (a local directory of userscripts, an http(s) directory or
3
+ // JSON manifest, a GitHub tree, or a "scripts" collection — a folder of
4
+ // individually installed scripts, user-created or one of the built-in
5
+ // defaults), with Remove / Edit / Refresh buttons and a script table
6
+ // showing name, size, and parse status.
7
+ //
8
+ // The Add field routes by what was typed: a single `.user.js` goes to
9
+ // the install page; anything else opens the create-collection dialog
10
+ // (src/extension/collection-dialog.ts), prefilled from a location or a name. The
11
+ // dialog probes a location before creating from it and loads every
12
+ // script it finds as part of the create, so the user doesn't need to
13
+ // click Refresh after add.
14
+ //
15
+ // The initial `render()` runs once at load to draw banners and seed
16
+ // every section. After that the page keeps itself current by REPAINTING
17
+ // FROM STORAGE: any write to the structural rows — by this page, the
18
+ // viewer, the background refresher, or another tab — marks it dirty, and
19
+ // the repaint re-reads storage and makes the DOM match. Sections and
20
+ // rows are reconciled by id and updated in place, so busy spinners,
21
+ // refresh highlights and focus survive. See docs/storage.md.
22
+ //
23
+ // The per-control toggles are the exception: they mutate their own row
24
+ // directly for immediate feedback, and show a small spinner while their
25
+ // write is in flight. The repaint is careful not to overwrite a control
26
+ // whose spinner is up.
27
+ import { scriptRowBytes } from './script-blobs.js';
28
+ import { openDeleteDialog } from './delete-dialog.js';
29
+ import { setDeleteIcon } from './delete-icon.js';
30
+ import { setGlyphButton, setGlyphButtonBusy } from './glyph.js';
31
+ import { deletableCopyPath, readCopiesSetting } from './local-delete.js';
32
+ import { DEFAULT_COLLECTIONS, describeError, needsDisplayName, getAllScriptMetas, getCollectionWithScriptMeta, getScriptMetas, getCollectionsById, getCollections, isFirstRunComplete, isScriptEnabled, isCollectionEnabled, readLocalStorageUsage, localFileScope, STORAGE_NEARLY_FULL, } from './script-store.js';
33
+ import { addCollection, updateAllCollections, updateCollection, updateScript, backfillCollectionDisplayNames, markFirstRunComplete, moveCollection, reconcileScriptMembership, removeCollection, syncAfterUserScriptsAllowed, removeScript, setAllScriptsEnabled, setCollectionEnabledExclusive, setScriptEnabledExclusive, } from './worker-client.js';
34
+ import { identityKey } from './script-identity.js';
35
+ import { watchSoloModifier } from './solo-click.js';
36
+ import { installLocalRootsListener, localRootsReady, localSearchRoots, } from '../local-file-overrides.js';
37
+ import { COLLECTION_OFF_TITLE, effectiveDisplayName, makeCollectionTypeBubble, makeSchemeChip, collectionAnchorId, EDIT_COLLECTION_HASH_PREFIX, } from './collection-display.js';
38
+ import { COLLECTION_PREFIX, SCRIPT_PREFIX, touchesStructuralRows } from './structural-keys.js';
39
+ import { openCollectionDialog } from './collection-dialog.js';
40
+ import { openExportDialog } from './export-dialog.js';
41
+ import { closeDetachedMenu, closeMenu, moreMenuButton, openMenu } from './more-menu.js';
42
+ import { onNamedTabFocus, openExtensionTab, openNamedTab } from './page-links.js';
43
+ import { getValue } from '../userscript-header.js';
44
+ import { describeDiagnostic } from '../diagnostic-location.js';
45
+ import { installDebugLoggingListener, isDebugLoggingEnabled, loadDebugLoggingFlag } from '../debug-log.js';
46
+ import { startPhaseTimer } from '../phase-timing.js';
47
+ import { appendWithSlashBreaks, compareScriptNames, displayLocation, effectiveScriptStatus, ENABLE_TITLE, formatBytes, formatStorageUsage, formatScriptSize, formatScriptStatusLabel, previewUrl, scriptBasename, scriptDisplayName, scriptOutdatedProbe, viewerUrl, } from '../script-display.js';
48
+ import { probeLabel } from '../version-probe.js';
49
+ import { LOG_LEVEL_KEY_PREFIX, getAllMaxLogLevels, } from './script-log.js';
50
+ import { REJECTIONS_KEY, coerceRejectionMap, readRejections, } from './script-rejection.js';
51
+ import { REFRESH_ERRORS_KEY, coerceRefreshErrorMap, describeRefreshError, readRefreshErrors, } from './refresh-error.js';
52
+ import { iconRefFromHeader } from '../script-icon.js';
53
+ import { preloadSiteIcons, renderSiteIconInto } from './site-icon.js';
54
+ import { mapWithConcurrency, FETCH_COLLECTIONS_CONCURRENCY, FETCH_SCRIPTS_PER_COLLECTION_CONCURRENCY, } from './concurrency.js';
55
+ import { localRefreshTargets, pathRefreshTargets, refreshPathProblem, REFRESH_FAILED_PREFIX, } from './refresh-targets.js';
56
+ import { canonicalCollectionInput, isContainerCollection, isSmDevCollection, isValidLocationShape, LOCATION_SHAPES, isSingleFileInput, normalizeCollectionInput, } from './directories.js';
57
+ import { markFileChanges, manifestToProbe, pendingOnDisk, refreshPendingHint, scriptFileTag, syncRefreshPendingChip, tagLocalFile, } from './file-changes.js';
58
+ import { installGithubAuthListener, loadGithubAuthToken, } from '../github-auth.js';
59
+ import { isFileSchemeAccessAllowed, renderFileAccessBanner, } from './file-access.js';
60
+ import { probeUserScriptsApi, USER_SCRIPTS_OFF_HEADING, userScriptsFixLine } from './user-scripts-access.js';
61
+ // The page needs its own copy of the cached GitHub token for the reads
62
+ // it still makes itself (the collection dialog's location probe).
63
+ // Refreshes run in the worker, which waits for its own copy.
64
+ installGithubAuthListener();
65
+ const githubTokenReady = loadGithubAuthToken();
66
+ const bannerSlot = document.getElementById('banner-slot');
67
+ const collectionsEl = document.getElementById('collections');
68
+ const addForm = document.getElementById('add-form');
69
+ const addInput = document.getElementById('add-path');
70
+ const addButton = addForm.querySelector('button[type="submit"]');
71
+ const refreshAllBtn = document.getElementById('refresh-all');
72
+ const openOptionsBtn = document.getElementById('open-options');
73
+ const addErrorEl = document.getElementById('add-error');
74
+ addForm.addEventListener('submit', (event) => {
75
+ event.preventDefault();
76
+ void onAddViaDialog(addInput.value, true);
77
+ });
78
+ // The per-collection Refresh, in one place: `renderCollection` builds
79
+ // the button with the idle label and `onRefreshCollection` puts it back
80
+ // after a refresh, so the two must not drift.
81
+ const REFRESH_COLLECTION_LABELS = {
82
+ idle: 'Refresh this collection',
83
+ busy: 'Refreshing this collection…',
84
+ };
85
+ setGlyphButton(refreshAllBtn, '#sm-refresh', 'Refresh all collections', { text: 'Refresh All' });
86
+ refreshAllBtn.addEventListener('click', () => void onRefreshAll());
87
+ // The page's ⋯ menu, right of Refresh All: actions on every collection.
88
+ const pageMenuBtn = moreMenuButton('More actions');
89
+ pageMenuBtn.id = 'page-menu';
90
+ pageMenuBtn.title = 'More actions';
91
+ pageMenuBtn.addEventListener('click', () => openMenu(pageMenuBtn, [
92
+ { label: 'Export all collections\u2026', run: () => openExportDialog({ scope: { all: true } }) },
93
+ ]));
94
+ refreshAllBtn.after(pageMenuBtn);
95
+ // The menu is fixed in place, so it would stay put while its button
96
+ // scrolls or the layout moves it.
97
+ window.addEventListener('scroll', closeMenu, { passive: true });
98
+ window.addEventListener('resize', closeMenu);
99
+ // A new tab, not this one: the collections page is where the user was
100
+ // working, and Options is a detour. `openNamedTab` reuses the one
101
+ // Options tab if it is already open rather than stacking up copies.
102
+ openOptionsBtn.addEventListener('click', () => {
103
+ void openNamedTab('options');
104
+ });
105
+ // Resolved once the first render has finished painting, which is what
106
+ // repaints wait for. `render()` empties the container and refills it
107
+ // across several awaits, and a repaint inside that window would build
108
+ // sections the render loop hasn't reached yet — each would append its
109
+ // own copy.
110
+ //
111
+ // Deliberately the RENDER, not all of `bootstrap()`. Bootstrap goes on
112
+ // to run first-run seeding, `?refresh` and `?add`, all of which
113
+ // go through handlers that repaint — waiting for bootstrap here would
114
+ // have those handlers waiting on themselves.
115
+ let resolveInitialRender;
116
+ const initialRenderDone = new Promise((resolve) => {
117
+ resolveInitialRender = resolve;
118
+ });
119
+ // A link elsewhere asking this tab to show a particular collection. The
120
+ // tab is already loaded, so a fragment in its URL would not move it —
121
+ // see src/extension/page-links.ts.
122
+ //
123
+ // Registered below `initialRenderDone`, which the handler reads: a
124
+ // message can only arrive in a later task, so the order is safe either
125
+ // way, but a `const` read before its declaration throws a
126
+ // ReferenceError inside a message listener, where nothing would report
127
+ // it.
128
+ onNamedTabFocus('collections', (hash) => { void focusHashTarget(hash); });
129
+ installLocalRootsListener();
130
+ void bootstrap().catch((err) => {
131
+ console.info('[SourceMonkey] collections page failed to start', err);
132
+ });
133
+ // Repaint Status cells when the per-script max-log-level row changes
134
+ // on the SW side (a fresh error / warning was appended, or the log
135
+ // was cleared), or when the injector rewrites the set of registrations
136
+ // Chrome refused. Filtering on those two keys keeps unrelated
137
+ // session-storage writes from kicking off pointless DOM walks.
138
+ if (chrome.storage?.session?.onChanged) {
139
+ chrome.storage.session.onChanged.addListener((changes) => {
140
+ let touched = false;
141
+ for (const key of Object.keys(changes)) {
142
+ if (key === REJECTIONS_KEY) {
143
+ // Whole-map key: the injector rewrites it as one authoritative
144
+ // set per sync, so we replace ours rather than merging.
145
+ currentRejections = coerceRejectionMap(changes[key]?.newValue);
146
+ touched = true;
147
+ continue;
148
+ }
149
+ if (key === REFRESH_ERRORS_KEY) {
150
+ // Also a whole-map key. Repainted rather than status-cell
151
+ // patched: it changes a section's message line and the page
152
+ // banner, neither of which `applyStatusCellsToDom` touches.
153
+ currentRefreshErrors = coerceRefreshErrorMap(changes[key]?.newValue);
154
+ void repaintPage();
155
+ continue;
156
+ }
157
+ if (!key.startsWith(LOG_LEVEL_KEY_PREFIX))
158
+ continue;
159
+ const scriptId = key.slice(LOG_LEVEL_KEY_PREFIX.length);
160
+ const next = changes[key]?.newValue;
161
+ if (next === 'info' || next === 'warning' || next === 'error') {
162
+ currentLogLevels.set(scriptId, next);
163
+ }
164
+ else {
165
+ currentLogLevels.delete(scriptId);
166
+ }
167
+ touched = true;
168
+ }
169
+ if (touched)
170
+ applyStatusCellsToDom();
171
+ });
172
+ }
173
+ // The debug-logging toggle is global, but each JS context reads it for
174
+ // itself — and until this, only the service worker did. That left every
175
+ // `dlog` / phase timer in page-context code permanently silent, which
176
+ // matters because the refresh pipeline runs HERE: the collections page
177
+ // imports `script-updater` directly rather than messaging the worker.
178
+ // The listener keeps the flag live, so flipping the Options checkbox
179
+ // takes effect without reloading the page.
180
+ installDebugLoggingListener();
181
+ void loadDebugLoggingFlag();
182
+ // Keep the page current with `chrome.storage.local` writes from ANY
183
+ // origin — the viewer's Refresh, a background opportunistic refresh, an
184
+ // Install captured in another tab, a second Collections tab. Before
185
+ // this the page only ever reflected the state it wrote itself, so a
186
+ // script that recovered elsewhere kept a stale badge until a manual
187
+ // reload (issue #10).
188
+ //
189
+ // The change event is treated as a "something you display moved" signal:
190
+ // only which keys changed is used, never the values it carries. The
191
+ // repaint re-reads storage and makes the DOM match, which is idempotent
192
+ // by construction: no snapshot to go stale, no per-key handler to keep
193
+ // in step with the store, and any number of events collapse into one
194
+ // pass. The keys narrow which rows it re-reads (`repaintScriptRows`).
195
+ if (chrome.storage?.local?.onChanged) {
196
+ chrome.storage.local.onChanged.addListener((changes) => {
197
+ // Only the keys the page draws from; an enable write's active times
198
+ // ride along in the same event.
199
+ const keys = Object.keys(changes).filter((key) => touchesStructuralRows([key]));
200
+ if (keys.length > 0)
201
+ void repaintPage(keys);
202
+ });
203
+ }
204
+ // Page-wide map of {scriptId → highest log level seen}. Drives the
205
+ // Status-cell promotion to WARNING / ERROR; absent entries collapse
206
+ // to OK (or whatever the stored status mandates). Kept in sync via
207
+ // the `chrome.storage.session.onChanged` listener installed at module
208
+ // top so a fresh log entry on the SW side updates the column without
209
+ // a full page reload. Seeded from `getAllMaxLogLevels()` inside
210
+ // `render()` so the first paint already reflects the current state.
211
+ let currentLogLevels = new Map();
212
+ // Page-wide map of {scriptId → the probe that says an upstream copy is
213
+ // ahead of ours}. Populated per row at render time; the repaint path
214
+ // rebuilds status cells from DOM datasets and has no script row to
215
+ // re-derive this from, so it reads the verdict back from here. Entries
216
+ // only ever appear for scripts whose `@updateURL` / `@downloadURL`
217
+ // points somewhere we don't fetch from — see src/version-probe.ts.
218
+ const currentOutdated = new Map();
219
+ // Page-wide {scriptId → why Chrome refused its registration}, mirroring
220
+ // the injector's session-storage map. Drives the REJECTED status; empty
221
+ // on a healthy profile. Seeded in `render()` and kept fresh by the
222
+ // `chrome.storage.session.onChanged` listener above.
223
+ let currentRejections = {};
224
+ // Page-wide {collectionId → why its last refresh couldn't be saved},
225
+ // mirroring the session-storage map in src/extension/refresh-error.ts. Drives both
226
+ // the per-section message and the storage banner; empty on a healthy
227
+ // profile. Seeded in `render()` and kept fresh by the
228
+ // `chrome.storage.session.onChanged` listener above.
229
+ let currentRefreshErrors = {};
230
+ // For the Status column: after a storage.session change for one or
231
+ // more `loglevel:` rows, walk every rendered script row and rewrite its
232
+ // status cell content + class to reflect the latest effective status. Pulls the ScriptMeta.status
233
+ // off the row's data-script-status attribute so we don't have to refetch.
234
+ function applyStatusCellsToDom() {
235
+ for (const row of document.querySelectorAll('.script-table tr[data-script-id]')) {
236
+ const id = row.dataset.scriptId;
237
+ const storedStatus = row.dataset.scriptStatus;
238
+ if (!id || !storedStatus)
239
+ continue;
240
+ const cell = row.querySelector('td.status-cell');
241
+ if (!cell)
242
+ continue;
243
+ paintStatusCell(cell, id, storedStatus, row.dataset.scriptError ?? undefined, row.dataset.scriptRefreshError ?? undefined);
244
+ }
245
+ }
246
+ // Write the `data-script-*` snapshot that the repaint paths read back,
247
+ // and hand the caller the description to paint with, so the initial
248
+ // render and the live in-place update can't drift. The error message is
249
+ // stored already-described (message + `(in file, line …)`) because the
250
+ // dataset is a string channel with no room for the structured location,
251
+ // and the hover text is its only consumer — the clickable form of the
252
+ // same location lives in the viewer, which this row's link opens.
253
+ // Fields that no longer apply are DELETED, not left behind: a stale
254
+ // `data-script-refresh-error` used to pin the recompute to REFRESH
255
+ // FAILED long after the refresh started succeeding again.
256
+ function applyScriptRowDatasets(row, script) {
257
+ row.dataset.scriptStatus = script.status;
258
+ const errorMessage = script.errorMessage
259
+ ? describeDiagnostic(script.errorMessage, script.errorLocation)
260
+ : undefined;
261
+ if (errorMessage)
262
+ row.dataset.scriptError = errorMessage;
263
+ else
264
+ delete row.dataset.scriptError;
265
+ if (script.refreshError)
266
+ row.dataset.scriptRefreshError = script.refreshError;
267
+ else
268
+ delete row.dataset.scriptRefreshError;
269
+ // The outdated verdict needs the parsed header and the stored probes,
270
+ // neither of which survives as a dataset attribute — record it in the
271
+ // page-wide map instead so the repaint path can look it up by id.
272
+ const outdated = scriptOutdatedProbe(script);
273
+ if (outdated)
274
+ currentOutdated.set(script.id, outdated);
275
+ else
276
+ currentOutdated.delete(script.id);
277
+ return { errorMessage };
278
+ }
279
+ // Everything a status cell is drawn from, as one comparable string.
280
+ // Recorded per row by `paintStatusCell` so a repaint can tell a cell
281
+ // that needs redrawing from one that would only lose the user's focus.
282
+ // The inputs live in three places: the stored row, the session-storage
283
+ // maps this page mirrors, and the version probes.
284
+ const statusCellKeys = new Map();
285
+ function statusCellKey(scriptId, storedStatus, errorMessage, refreshError) {
286
+ return JSON.stringify([
287
+ storedStatus,
288
+ errorMessage ?? '',
289
+ refreshError ?? '',
290
+ currentLogLevels.get(scriptId) ?? '',
291
+ currentOutdated.get(scriptId) ?? null,
292
+ currentRejections[scriptId] ?? '',
293
+ ]);
294
+ }
295
+ // Apply `effectiveScriptStatus` to one status cell: rewrite the link
296
+ // label, classes, and trash-can affordance. Used by both initial
297
+ // render and the post-storage-change repaint so the two paths can't
298
+ // drift on label / class details.
299
+ function paintStatusCell(cell, scriptId, storedStatus, errorMessage, refreshError) {
300
+ statusCellKeys.set(scriptId, statusCellKey(scriptId, storedStatus, errorMessage, refreshError));
301
+ cell.textContent = '';
302
+ cell.className = 'status-cell';
303
+ const level = currentLogLevels.get(scriptId);
304
+ const outdated = currentOutdated.get(scriptId);
305
+ const rejection = currentRejections[scriptId];
306
+ const effective = effectiveScriptStatus({ status: storedStatus, refreshError }, level, outdated !== undefined, rejection !== undefined);
307
+ cell.classList.add(`status-${effective}`);
308
+ // Anchor that takes the user straight to the script viewer's Log
309
+ // tab — the same destination regardless of effective status, since
310
+ // even OK rows may have informative entries.
311
+ const link = document.createElement('a');
312
+ link.href = viewerUrl(scriptId, 'log');
313
+ link.target = '_blank';
314
+ link.rel = 'noopener noreferrer';
315
+ link.textContent = formatScriptStatusLabel(effective);
316
+ // Hover text follows whatever the cell is actually showing: Chrome's
317
+ // rejection reason, the refresh-failure reason, the newer version we
318
+ // found upstream, or otherwise the load errorMessage.
319
+ const title = effective === 'rejected' && rejection
320
+ ? rejection
321
+ : effective === 'refresh-failed'
322
+ ? refreshError
323
+ : effective === 'outdated' && outdated
324
+ ? `Version ${outdated.version} at ${probeLabel(outdated.kinds)}: ${outdated.url}`
325
+ : errorMessage;
326
+ if (title)
327
+ link.title = title;
328
+ cell.appendChild(link);
329
+ }
330
+ // ---------- Live repaint from storage ----------
331
+ // The page is repainted from storage whenever anything it displays may
332
+ // have moved, rather than by interpreting individual change events.
333
+ //
334
+ // Every repaint re-reads the world and makes the DOM match it, so the
335
+ // pass is idempotent: running it twice changes nothing, running it after
336
+ // a burst of writes lands on the final state, and the echo of the page's
337
+ // own write is a no-op rather than a fight with the handler that made
338
+ // it. Events only decide WHEN to repaint, never what to paint — a
339
+ // change event carries a snapshot, and a snapshot acted on later is a
340
+ // lie about the present.
341
+ let pageDirty = false;
342
+ // The changed keys the pending repaint has to cover, or null for all of
343
+ // them. Lets a change to a few script rows repaint just those rows (see
344
+ // `repaintScriptRows`).
345
+ let dirtyKeys = new Set();
346
+ let repaintChain = Promise.resolve();
347
+ // Mark the page stale and resolve once a repaint has covered it. Passes
348
+ // are serialized, and a burst of events collapses: the first link to run
349
+ // clears the flag and does the work, and the rest find nothing to do.
350
+ // Awaiting the returned promise is enough for a caller to know the DOM
351
+ // matches storage as of its own call — its link runs no earlier than the
352
+ // repaint that covered it.
353
+ //
354
+ // Deliberately not debounced. The repaint is cheap, and a delay would
355
+ // only put lag between a user's click and the page agreeing with it.
356
+ //
357
+ // `keys`: the storage keys that changed, when the caller knows them.
358
+ // Without them the pass repaints everything.
359
+ function repaintPage(keys) {
360
+ pageDirty = true;
361
+ if (!keys)
362
+ dirtyKeys = null;
363
+ else if (dirtyKeys)
364
+ for (const key of keys)
365
+ dirtyKeys.add(key);
366
+ repaintChain = repaintChain
367
+ .then(async () => {
368
+ // Waited before the flag is read, not after, so everything that
369
+ // arrives during the initial render collapses into ONE repaint
370
+ // rather than one per event.
371
+ await initialRenderDone;
372
+ if (!pageDirty)
373
+ return;
374
+ pageDirty = false;
375
+ const changed = dirtyKeys;
376
+ dirtyKeys = new Set();
377
+ await repaintFromStorage(changed);
378
+ })
379
+ .catch((err) => {
380
+ // Nothing here is worth failing the page over, but an unhandled
381
+ // rejection would land in chrome://extensions/?errors and read as
382
+ // a broken extension — keep it in the page console.
383
+ console.info('[SourceMonkey] live update failed', err);
384
+ });
385
+ return repaintChain;
386
+ }
387
+ // Make the rendered sections match the stored collections: drop the ones
388
+ // that are gone, build the ones that appeared, update the rest in place,
389
+ // and re-seat them into index order (`repaintSections`), or, when only
390
+ // script rows changed, update just those rows (`repaintScriptRows`).
391
+ // Existing elements are reused so refresh highlights, in-flight
392
+ // spinners, and focus survive.
393
+ //
394
+ // `changed` is the keys that changed since the last pass, or null when
395
+ // unknown. A change to script rows only repaints those rows; anything
396
+ // else, or a change those rows can't take in place, repaints all.
397
+ async function repaintFromStorage(changed) {
398
+ const timer = startPhaseTimer('collections page repaint');
399
+ const rows = changed ? await repaintScriptRows(changed, timer) : null;
400
+ if (rows === null)
401
+ await repaintSections(timer);
402
+ timer.note(rows === null ? 'all rows' : `${rows} row(s)`);
403
+ // Rows this repaint read after their write are settled.
404
+ for (const [key, at] of writtenAt)
405
+ if (at <= repaintReadAt)
406
+ writtenAt.delete(key);
407
+ timer.done();
408
+ }
409
+ // Enable writes this page made, numbered, and the number each row
410
+ // (script id, or `collection:` + id) last had one land at. A repaint
411
+ // notes the number before it reads storage, and leaves alone the
412
+ // checkbox of any row written since: it reads for hundreds of ms on a
413
+ // large store, so a click that lands meanwhile would otherwise be drawn
414
+ // over with what the row was before it.
415
+ //
416
+ // Nothing is left stale by skipping: the handler has already drawn the
417
+ // row from the write's result. The repaint that write's storage change
418
+ // starts usually reads after it is noted and draws the row as well; if
419
+ // it began first, the next repaint does, and clears the entry.
420
+ let pageWrites = 0;
421
+ let repaintReadAt = 0;
422
+ const writtenAt = new Map();
423
+ function noteWritten(keys) {
424
+ pageWrites++;
425
+ for (const key of keys)
426
+ writtenAt.set(key, pageWrites);
427
+ }
428
+ function writtenSinceRead(key) {
429
+ return (writtenAt.get(key) ?? 0) > repaintReadAt;
430
+ }
431
+ // Show an enable write on every row it changed: the copies it turned
432
+ // off elsewhere on the page as well as the one clicked, together, so
433
+ // two copies never both show as on while the repaint catches up.
434
+ //
435
+ // Applied under another click's spinner too. The worker runs the writes
436
+ // in order (the store's lock) and answers each as it finishes, so the
437
+ // last result applied is the last write.
438
+ //
439
+ // `also` is other rows the handler drew itself (the clicked one, a
440
+ // collection's toggle), noted as written with these.
441
+ function applyEnableResult(changed, also = []) {
442
+ noteWritten([...also, ...changed.map((s) => s.id)]);
443
+ const sections = new Set();
444
+ for (const script of changed) {
445
+ const row = collectionsEl.querySelector(`tr[data-script-id="${cssEscape(script.id)}"]`);
446
+ if (!row)
447
+ continue;
448
+ const on = isScriptEnabled(script);
449
+ const cb = row.querySelector('.toggle input[type="checkbox"]');
450
+ if (cb)
451
+ cb.checked = on;
452
+ const section = row.closest('.collection');
453
+ const collectionOn = section ? !section.classList.contains('disabled') : true;
454
+ row.classList.toggle('disabled', !collectionOn || !on);
455
+ if (section)
456
+ sections.add(section);
457
+ }
458
+ for (const section of sections)
459
+ refreshBulkToggle(section);
460
+ }
461
+ // Repaint only the rows of the scripts in `changed`, when that's all
462
+ // that changed: an enable toggle, a version probe, a refresh of a few
463
+ // scripts. Those rows are read again from storage, as the full repaint
464
+ // reads every row, rather than taken from the change event.
465
+ //
466
+ // Returns how many rows it repainted, or null, having changed nothing,
467
+ // when the change needs the full repaint:
468
+ // - a collection row or the collection list changed;
469
+ // - a script appeared, went, or moved to another collection (those
470
+ // write the collection rows too, so this is a backstop);
471
+ // - a row's shown name changed, which can move it in the section's
472
+ // name order.
473
+ async function repaintScriptRows(changed, timer) {
474
+ const ids = [];
475
+ for (const key of changed) {
476
+ if (!key.startsWith(SCRIPT_PREFIX))
477
+ return null;
478
+ ids.push(key.slice(SCRIPT_PREFIX.length));
479
+ }
480
+ repaintReadAt = pageWrites;
481
+ const scripts = await getScriptMetas(ids);
482
+ timer.mark('read scripts', `${scripts.length} scripts`);
483
+ if (scripts.length !== ids.length)
484
+ return null;
485
+ const placed = [];
486
+ for (const script of scripts) {
487
+ const row = collectionsEl.querySelector(`tr[data-script-id="${cssEscape(script.id)}"]`);
488
+ const section = row?.closest('section.collection');
489
+ if (!row || !section || section.dataset.collectionId !== script.collectionId)
490
+ return null;
491
+ placed.push({ row, section, script });
492
+ }
493
+ const collectionIds = [...new Set(scripts.map((s) => s.collectionId))];
494
+ const collections = new Map((await getCollectionsById(collectionIds)).map((c) => [c.id, c]));
495
+ timer.mark('read collections');
496
+ if (collections.size !== collectionIds.length)
497
+ return null;
498
+ if (placed.some(({ script }) => shownNames.get(script.id)
499
+ !== nameCellText(script, isContainerCollection(collections.get(script.collectionId).path)))) {
500
+ return null;
501
+ }
502
+ // As the full repaint does, so a rebuilt Name cell draws its icon
503
+ // straight away.
504
+ await preloadSiteIcons(scripts);
505
+ timer.mark('icons');
506
+ const sections = new Set();
507
+ const settling = new Map();
508
+ for (const { row, section, script } of placed) {
509
+ const collection = collections.get(script.collectionId);
510
+ // Same rule as `syncSectionFromStorage`: a toggle of the collection
511
+ // this page made after the read already drew the section.
512
+ const collectionOn = writtenSinceRead(COLLECTION_PREFIX + collection.id)
513
+ ? !section.classList.contains('disabled')
514
+ : isCollectionEnabled(collection);
515
+ const repainted = syncScriptRow(row, script, collectionOn, collection.path, localFileScope(collection));
516
+ const table = row.closest('table');
517
+ if (repainted && table)
518
+ settling.set(table, [...(settling.get(table) ?? []), repainted]);
519
+ sections.add(section);
520
+ }
521
+ // A rebuilt Name cell may need a different icon column width.
522
+ for (const [table, pending] of settling)
523
+ fitIconColumns(table, pending);
524
+ for (const section of sections)
525
+ refreshBulkToggle(section);
526
+ // Its usage figure moves with any write, while it's up.
527
+ const liveIds = [...collectionsEl.querySelectorAll('section.collection')]
528
+ .map((s) => s.dataset.collectionId ?? '');
529
+ await syncStorageBanner(new Set(liveIds));
530
+ markDiskChanges();
531
+ timer.mark('paint');
532
+ return placed.length;
533
+ }
534
+ async function repaintSections(timer) {
535
+ repaintReadAt = pageWrites;
536
+ const collections = await getCollections();
537
+ timer.mark('read collections');
538
+ // Ahead of the sections, so a page that just failed to save shows the
539
+ // explanation at the top by the time the reader's eye follows the
540
+ // section messages down. Needs the collection list, hence after the
541
+ // read rather than first.
542
+ await syncStorageBanner(new Set(collections.map((s) => s.id)));
543
+ timer.mark('banner');
544
+ // Read every collection's script rows ONCE, up front, in one call,
545
+ // and paint from that.
546
+ // - Without their bodies, which the rows don't show.
547
+ // - Not `getAllScriptMetas()`: that lists every key first, for rows
548
+ // the collection list above already names.
549
+ // - Not one read per collection: two calls each, one after another.
550
+ // A repaint runs on any structural write from anywhere, so it has to
551
+ // stay proportionate.
552
+ const scripts = await getScriptMetas(collections.flatMap((c) => c.scriptIds));
553
+ const byId = new Map(scripts.map((sc) => [sc.id, sc]));
554
+ const scriptsByCollection = new Map(collections.map((c) => [
555
+ c.id,
556
+ c.scriptIds.map((id) => byId.get(id)).filter((sc) => sc != null),
557
+ ]));
558
+ const allScripts = [...scriptsByCollection.values()].flat();
559
+ timer.mark('read scripts', `${allScripts.length} scripts`);
560
+ // Their size from Chrome rather than by serializing them here, which
561
+ // on a store of several MB held this page's thread for tens of ms.
562
+ // The rows only, which is what the read above loaded. Only with debug
563
+ // logging on, since it is an extra storage call.
564
+ if (isDebugLoggingEnabled()) {
565
+ const bytes = await scriptRowBytes(allScripts.map((s) => s.id));
566
+ timer.mark('size', formatBytes(bytes));
567
+ }
568
+ // Cached favicons first, so rows draw with their real icons rather
569
+ // than globes that are swapped (and the icon column re-fitted) a
570
+ // moment later.
571
+ await preloadSiteIcons(allScripts);
572
+ timer.mark('icons');
573
+ // Forget the per-script bookkeeping of everything no longer stored,
574
+ // in one place rather than at each of the several points a row can go
575
+ // away — and keyed off what storage says, so a script that moved
576
+ // between collections isn't forgotten by its old section after its
577
+ // new one has already recorded it.
578
+ forgetScriptsExcept(new Set(allScripts.map((s) => s.id)));
579
+ const wanted = new Set(collections.map((s) => s.id));
580
+ for (const section of [
581
+ ...collectionsEl.querySelectorAll('section.collection'),
582
+ ]) {
583
+ const id = section.dataset.collectionId;
584
+ if (!id || !wanted.has(id))
585
+ section.remove();
586
+ }
587
+ if (collections.length === 0) {
588
+ if (!collectionsEl.querySelector('.empty-placeholder')) {
589
+ collectionsEl.appendChild(emptyPlaceholder());
590
+ }
591
+ refreshMoveButtons();
592
+ timer.mark('paint');
593
+ return;
594
+ }
595
+ removeEmptyPlaceholder();
596
+ const ordered = [];
597
+ for (const collection of collections) {
598
+ const scripts = scriptsByCollection.get(collection.id) ?? [];
599
+ const section = findSection(collection.id);
600
+ if (section)
601
+ await syncSectionFromStorage(section, collection, scripts);
602
+ ordered.push(section ?? await renderCollectionSection(collection));
603
+ }
604
+ // appendChild moves an already-attached node, so appending each in
605
+ // index order re-seats them. Only done when the order actually
606
+ // differs: re-seating detaches the node first, and Chrome blurs
607
+ // anything focused inside it.
608
+ if (!sameNodeOrder(collectionsEl, ordered)) {
609
+ for (const section of ordered)
610
+ collectionsEl.appendChild(section);
611
+ }
612
+ refreshMoveButtons();
613
+ markDiskChanges();
614
+ closeDetachedMenu();
615
+ timer.mark('paint');
616
+ }
617
+ // What the disk says that storage doesn't yet (file-changes.ts): a
618
+ // chip after the name of every local script whose file has gone or
619
+ // changed, and a "refresh pending" chip on a directory collection
620
+ // whose heading tooltip sums up what a refresh would do — deleted and
621
+ // modified rows, plus `.user.js` files on disk that no row accounts
622
+ // for. Run after each paint and repaint: a repaint follows every
623
+ // storage write, including the refresh that applies these, so the
624
+ // chips are short-lived by design — the gap between the disk changing
625
+ // and SourceMonkey noticing.
626
+ //
627
+ // One directory listing per folder on the page — but not per repaint:
628
+ // a repaint also follows an enable toggle, a log write, a refresh of
629
+ // another collection, none of which touch the disk, so a listing read
630
+ // in the last few seconds is reused. A change shows up on the first
631
+ // repaint after that window, or on reload.
632
+ function markDiskChanges() {
633
+ void (async () => {
634
+ const states = await markFileChanges(collectionsEl, DISK_LISTING_MAX_AGE_MS);
635
+ for (const section of collectionsEl.querySelectorAll('section.collection')) {
636
+ const collectionPath = section.dataset.collectionPath;
637
+ if (!collectionPath)
638
+ continue;
639
+ const rows = [...section.querySelectorAll('tr[data-script-id] [data-fs-path]')];
640
+ // The manifest file itself, against the stat its last scan
641
+ // recorded (`manifestSize` / `manifestMtime`).
642
+ const { manifestFsPath, manifestSize, manifestMtime } = section.dataset;
643
+ const manifest = manifestFsPath === undefined ? null : {
644
+ path: manifestFsPath,
645
+ size: manifestSize === undefined ? undefined : Number(manifestSize),
646
+ mtime: manifestMtime === undefined ? undefined : Number(manifestMtime),
647
+ };
648
+ const counts = await pendingOnDisk(collectionPath, manifest, rows.map((el) => ({ fsPath: el.dataset.fsPath, state: states.get(el) })), DISK_LISTING_MAX_AGE_MS);
649
+ if (!counts)
650
+ continue;
651
+ const nameEl = section.querySelector('.collection-header .collection-display-name');
652
+ if (nameEl)
653
+ syncRefreshPendingChip(nameEl, refreshPendingHint(counts));
654
+ }
655
+ })();
656
+ }
657
+ const DISK_LISTING_MAX_AGE_MS = 5000;
658
+ // A `file://` manifest's path and last-scanned stat, on the section for
659
+ // `markDiskChanges`. Kept current on every sync: a refresh rewrites
660
+ // the stat, and the chip has to compare against the new one.
661
+ function syncManifestStatDataset(section, collection) {
662
+ const ds = section.dataset;
663
+ // The same manifest sm-dev's `onDisk` probes.
664
+ const probe = manifestToProbe(collection);
665
+ if (probe)
666
+ ds.manifestFsPath = probe.path;
667
+ else
668
+ delete ds.manifestFsPath;
669
+ if (collection.manifestSize !== undefined)
670
+ ds.manifestSize = String(collection.manifestSize);
671
+ else
672
+ delete ds.manifestSize;
673
+ if (collection.manifestMtime !== undefined)
674
+ ds.manifestMtime = String(collection.manifestMtime);
675
+ else
676
+ delete ds.manifestMtime;
677
+ }
678
+ // Update one already-rendered section from its stored collection row.
679
+ // The section element itself is never replaced — that would take the
680
+ // Refresh button's busy state and every row's spinner with it.
681
+ async function syncSectionFromStorage(section, collection, scripts) {
682
+ // A toggle of this collection that landed after the repaint read
683
+ // storage already drew the section; the stored row is older than it.
684
+ const stale = writtenSinceRead(COLLECTION_PREFIX + collection.id);
685
+ const collectionOn = stale ? !section.classList.contains('disabled') : isCollectionEnabled(collection);
686
+ if (!stale)
687
+ applyCollectionEnabledToDom(section, collectionOn);
688
+ // The type bubble is derived from `collection.path`, which is the
689
+ // collection's identity and never rewritten, so only the display name
690
+ // can move.
691
+ syncManifestStatDataset(section, collection);
692
+ // Compared against what was painted, not `textContent`: the name
693
+ // element can also hold the "refresh pending" chip (file-changes.ts),
694
+ // whose text would make every repaint look like a rename.
695
+ const nameEl = section.querySelector('.collection-display-name');
696
+ const displayName = effectiveDisplayName(collection);
697
+ if (nameEl && nameEl.dataset.displayName !== displayName) {
698
+ nameEl.textContent = '';
699
+ appendWithSlashBreaks(nameEl, displayName);
700
+ nameEl.dataset.displayName = displayName;
701
+ }
702
+ syncSectionMessage(section, 'scan', scanErrorText(collection));
703
+ syncSectionMessage(section, 'refresh', refreshErrorText(collection.id));
704
+ await syncScriptRowsFromStorage(section, collection, collectionOn, scripts);
705
+ }
706
+ // The section's message lines, in the order they are drawn between the
707
+ // header and the script list. A section can carry both at once — they
708
+ // describe different attempts with different fixes — so each is tagged
709
+ // and synced on its own rather than by "the first `p.error`", which
710
+ // would have the two overwrite each other.
711
+ const SECTION_MESSAGE_KINDS = ['scan', 'refresh'];
712
+ function makeSectionMessage(kind, text) {
713
+ const p = makeMessage('error', '');
714
+ p.dataset.messageKind = kind;
715
+ fillWithLinks(p, text);
716
+ return p;
717
+ }
718
+ // `text` into `el`, with each http(s) URL in it as a link (a failed
719
+ // read names the address it read). Punctuation right after a URL stays
720
+ // text: `Could not read <url>: HTTP 404` puts a colon there.
721
+ //
722
+ // Skipped when `el` already shows `text`: a repaint would otherwise
723
+ // rebuild a link the pointer or focus is on.
724
+ function fillWithLinks(el, text) {
725
+ if (el.dataset.text === text)
726
+ return;
727
+ el.dataset.text = text;
728
+ el.textContent = '';
729
+ let at = 0;
730
+ for (const match of text.matchAll(/https?:\/\/\S+/g)) {
731
+ const url = match[0].replace(/[.,;:!?)\]]+$/, '');
732
+ el.append(text.slice(at, match.index));
733
+ const a = document.createElement('a');
734
+ a.href = url;
735
+ a.textContent = url;
736
+ a.target = '_blank';
737
+ a.rel = 'noopener';
738
+ el.append(a);
739
+ at = match.index + url.length;
740
+ }
741
+ el.append(text.slice(at));
742
+ }
743
+ // Add, update, or drop one of a section's message lines to match `text`
744
+ // (empty meaning "no message of this kind"). Shared by the initial build
745
+ // and the repaint so the two can't render the same failure differently.
746
+ function syncSectionMessage(section, kind, text) {
747
+ const prior = section.querySelector(`:scope > p[data-message-kind="${kind}"]`);
748
+ if (!text) {
749
+ prior?.remove();
750
+ return;
751
+ }
752
+ if (prior) {
753
+ fillWithLinks(prior, text);
754
+ return;
755
+ }
756
+ // Insert after whichever earlier line is present, so the kinds keep a
757
+ // stable order however they arrive — a section that gains its scan
758
+ // line second must not end up showing it below the refresh line.
759
+ let anchor = section.querySelector('.collection-header');
760
+ for (const earlier of SECTION_MESSAGE_KINDS) {
761
+ if (earlier === kind)
762
+ break;
763
+ anchor = section.querySelector(`:scope > p[data-message-kind="${earlier}"]`) ?? anchor;
764
+ }
765
+ anchor?.after(makeSectionMessage(kind, text));
766
+ }
767
+ // Update the section's script list in place: reuse the `<tr>` for a
768
+ // script that is still there, build one for a script that appeared, drop
769
+ // the rows whose scripts are gone, and re-seat into name order.
770
+ async function syncScriptRowsFromStorage(section, collection, collectionOn, scripts) {
771
+ const localFiles = localFileScope(collection);
772
+ scripts = sortedByShownName(scripts, isContainerCollection(collection.path));
773
+ const block = section.querySelector(':scope > .script-block');
774
+ const tbody = block?.querySelector('tbody');
775
+ // A collection with no scripts shows a message instead of a table, so
776
+ // the two shapes have to be swapped whole. Everything else is in
777
+ // place.
778
+ if (scripts.length === 0 || !tbody) {
779
+ if (scripts.length === 0 && !tbody)
780
+ return; // already the message
781
+ const fresh = await renderScriptBlock(collection, collectionOn);
782
+ if (block)
783
+ block.replaceWith(fresh);
784
+ else
785
+ section.appendChild(fresh);
786
+ return;
787
+ }
788
+ const existing = new Map();
789
+ for (const row of liveScriptRows(tbody)) {
790
+ if (row.dataset.scriptId)
791
+ existing.set(row.dataset.scriptId, row);
792
+ }
793
+ const hasActionColumn = !!tbody.parentElement?.querySelector('thead th[aria-label="Actions"]');
794
+ const ordered = [];
795
+ // Rows whose icon slot was (re)built in this pass; their favicons are
796
+ // still loading, and the column is fitted again once they settle.
797
+ const settling = [];
798
+ for (const script of scripts) {
799
+ const row = existing.get(script.id);
800
+ if (row) {
801
+ const repainted = syncScriptRow(row, script, collectionOn, collection.path, localFiles);
802
+ if (repainted)
803
+ settling.push(repainted);
804
+ existing.delete(script.id);
805
+ }
806
+ // Whether the table has the trailing action column is settled by
807
+ // the header that is already on screen — a row added into it has to
808
+ // match, and a table whose whole point is local copies already has
809
+ // one from the render that built it.
810
+ if (row) {
811
+ ordered.push(row);
812
+ continue;
813
+ }
814
+ const made = renderScriptRow(script, collectionOn, collection.path, localFiles, hasActionColumn);
815
+ ordered.push(made.row);
816
+ settling.push(made.iconCount);
817
+ }
818
+ // Whatever is left in `existing` is a row whose script is gone. The
819
+ // per-script bookkeeping is pruned by the caller, against the whole
820
+ // stored set.
821
+ for (const row of existing.values())
822
+ row.remove();
823
+ // Compared against the live rows only — a tombstone sitting between
824
+ // them would make every repaint see a mismatch and re-seat the whole
825
+ // table, which detaches rows and blurs anything focused inside one.
826
+ if (!sameNodeList(liveScriptRows(tbody), ordered)) {
827
+ for (const row of ordered)
828
+ tbody.appendChild(row);
829
+ }
830
+ // Re-seating the live rows leaves the ghosts wherever they were, so
831
+ // they are placed again against their anchors afterwards.
832
+ placeTombstones(tbody);
833
+ // Rows came and went, so the icon column is fitted again: a row that
834
+ // left may have been the wide one, a row that arrived may need more.
835
+ fitIconColumns(tbody.parentElement, settling);
836
+ refreshBulkToggle(section);
837
+ }
838
+ // Repaint one already-rendered script row from its stored value WITHOUT
839
+ // recreating the <tr>, so any refresh highlight or in-flight spinner on
840
+ // the row survives. This is the heart of the issue-#10 fix: the row's
841
+ // datasets are rewritten from the stored value — and the ones that no
842
+ // longer apply are DELETED — so a cleared `refreshError` finally reaches
843
+ // the repaint instead of a stale `data-script-refresh-error` pinning the
844
+ // cell to REFRESH FAILED.
845
+ function syncScriptRow(row, script, collectionOn, collectionPath, localFiles) {
846
+ const inContainer = isContainerCollection(collectionPath);
847
+ const { errorMessage } = applyScriptRowDatasets(row, script);
848
+ // Like the Name cell below, the Status cell is only rebuilt when
849
+ // what it shows moved. `paintStatusCell` discards and recreates the
850
+ // `<a>`, so repainting it
851
+ // unconditionally would drop focus off it — and a repaint runs on any
852
+ // structural write anywhere, including a background refresh of an
853
+ // unrelated collection.
854
+ const cell = row.querySelector('td.status-cell');
855
+ const wanted = statusCellKey(script.id, script.status, errorMessage, script.refreshError);
856
+ if (cell && statusCellKeys.get(script.id) !== wanted) {
857
+ paintStatusCell(cell, script.id, script.status, errorMessage, script.refreshError);
858
+ }
859
+ // The action cell moves with the row's status: a copy whose file has
860
+ // gone stops offering to delete that file, and `paintStatusCell` has
861
+ // just given the row the plain trash can that clears its record. Two
862
+ // trash cans on one row, one of them acting on a file that isn't
863
+ // there, is what leaving this cell alone looked like.
864
+ const actionCell = row.querySelector('td.action-cell');
865
+ if (actionCell && !inContainer) {
866
+ const wanted = isDeletableCopy(script, localFiles.collectionPath);
867
+ const has = actionCell.childElementCount > 0;
868
+ if (wanted && !has)
869
+ actionCell.appendChild(makeDeleteCopyButton(script));
870
+ else if (!wanted && has)
871
+ actionCell.replaceChildren();
872
+ }
873
+ const sizeCell = row.querySelector('td.size-cell');
874
+ const size = formatScriptSize(script);
875
+ if (sizeCell && sizeCell.textContent !== size)
876
+ sizeCell.textContent = size;
877
+ // The Name cell is only rebuilt when something it shows actually
878
+ // moved. Rebuilding unconditionally would re-run the favicon fetch and
879
+ // flash the globe placeholder on every unrelated repaint.
880
+ const nameCell = row.querySelector('td.name-cell');
881
+ let repainted = null;
882
+ if (nameCell && nameCellKeys.get(script.id) !== nameCellKey(script, localFiles)) {
883
+ // A refresh that changed `@match` may need a different icon
884
+ // column; the caller re-fits the table once it has seen every row.
885
+ repainted = fillNameCell(nameCell, script, collectionPath, localFiles);
886
+ }
887
+ // A refresh can change `@name` / `@namespace`.
888
+ row.dataset.identity = rowIdentity(script);
889
+ const label = row.querySelector('.toggle');
890
+ const cb = label?.querySelector('input[type="checkbox"]');
891
+ // Skip the checkbox while THIS row's toggle is mid-flight (a
892
+ // `.busy-spinner` sibling means an onToggleScript on this page holds
893
+ // it disabled with its intended value shown). The echo of that very
894
+ // write must not re-enable it under the spinner; the handler's
895
+ // `finally` restores the final state. Everything else (status, size)
896
+ // still updates.
897
+ //
898
+ // `checked` is also skipped when a write from this page landed on the
899
+ // row after this repaint read storage: what it read is older than what
900
+ // the row shows (see `noteWritten`). `disabled` follows the collection,
901
+ // which that write didn't change.
902
+ const busy = !!label?.querySelector('.busy-spinner');
903
+ const stale = writtenSinceRead(script.id);
904
+ if (cb && !busy) {
905
+ if (!stale)
906
+ cb.checked = isScriptEnabled(script);
907
+ cb.disabled = !collectionOn;
908
+ }
909
+ const scriptOn = stale && cb ? cb.checked : isScriptEnabled(script);
910
+ // Mirror applyCollectionEnabledToDom's label upkeep, spinner or not:
911
+ // this is the parent collection's state, not the user's click, and
912
+ // the restore in setToggleBusy reads `toggle-disabled` back to settle
913
+ // the checkbox.
914
+ if (label) {
915
+ label.classList.toggle('toggle-disabled', !collectionOn);
916
+ label.title = collectionOn ? ENABLE_TITLE : COLLECTION_OFF_TITLE;
917
+ }
918
+ row.classList.toggle('disabled', !collectionOn || !scriptOn);
919
+ return repainted;
920
+ }
921
+ // Are `wanted` already the container's element children, in that order?
922
+ function sameNodeOrder(container, wanted) {
923
+ return sameNodeList([...container.children], wanted);
924
+ }
925
+ function sameNodeList(current, wanted) {
926
+ return (current.length === wanted.length && current.every((node, i) => node === wanted[i]));
927
+ }
928
+ // Everything the Name cell renders, as one comparable string: the
929
+ // name text, its location + description tooltip, and the favicon's inputs
930
+ // (`renderSiteIconInto` reads `matching`, the header's `@icon`, and
931
+ // `fileUrl` for resolving a relative one). Recorded per row by
932
+ // `fillNameCell` so a repaint can tell a cell that needs rebuilding from
933
+ // one that would only flash its favicon for nothing.
934
+ const nameCellKeys = new Map();
935
+ // The name each row was last drawn with, which sets its place in the
936
+ // section (`sortedByShownName`). A repaint of only some rows can't
937
+ // re-sort the section, so it hands one whose name moved to the full
938
+ // repaint.
939
+ const shownNames = new Map();
940
+ function nameCellKey(script, localFiles) {
941
+ return JSON.stringify([
942
+ script.relativePath,
943
+ script.fileUrl,
944
+ // The header values the name text and its tooltip are built from,
945
+ // raw rather than formatted — comparing the inputs is enough.
946
+ script.header && getValue(script.header, 'name'),
947
+ script.header && getValue(script.header, 'description'),
948
+ // `inContainer`, the other input to the name text, is deliberately
949
+ // absent: it's a property of the collection the script sits in, and
950
+ // a script that moves between collections is a different row.
951
+ iconRefFromHeader(script.header),
952
+ script.matching,
953
+ // The collection's local-files scope decides whether the `@icon` is
954
+ // fetched from its declared URL or from a file on disk, so flipping
955
+ // the toggle changes what this cell paints. Without it here,
956
+ // toggling the switch would repaint the page and leave the old
957
+ // favicon in place until something unrelated rebuilt the row.
958
+ // Flattened rather than stringified whole: the key must not depend
959
+ // on the scope object's property order.
960
+ localFiles.enabled,
961
+ localFiles.collectionPath ?? '',
962
+ // The size and mtime the name link is tagged with for the
963
+ // "modified" chip (file-changes.ts): a refresh that re-reads the
964
+ // file moves them, and the tag has to move too or the chip would
965
+ // outlive the refresh that applied it.
966
+ script.size,
967
+ script.fsMtime,
968
+ ]);
969
+ }
970
+ // Drop the per-script bookkeeping for everything that is no longer
971
+ // stored. These maps are keyed by script id and would otherwise grow for
972
+ // the life of the page.
973
+ function forgetScriptsExcept(keep) {
974
+ for (const id of [...nameCellKeys.keys()]) {
975
+ if (!keep.has(id))
976
+ nameCellKeys.delete(id);
977
+ }
978
+ for (const id of [...shownNames.keys()]) {
979
+ if (!keep.has(id))
980
+ shownNames.delete(id);
981
+ }
982
+ for (const id of [...currentOutdated.keys()]) {
983
+ if (!keep.has(id))
984
+ currentOutdated.delete(id);
985
+ }
986
+ for (const id of [...statusCellKeys.keys()]) {
987
+ if (!keep.has(id))
988
+ statusCellKeys.delete(id);
989
+ }
990
+ }
991
+ // `?refresh` in the URL triggers a refresh-all as soon as the initial
992
+ // render is done. Lets external tools poke the extension via
993
+ // google-chrome chrome-extension://<id>/collections.html?refresh
994
+ // after they've written new userscripts.
995
+ //
996
+ // Two narrower triggers exist for the common "I just edited files on
997
+ // disk" case, where a refresh-all would also re-read every http(s) and
998
+ // GitHub collection for nothing:
999
+ // - `?refresh_local` — every local collection, and nothing else.
1000
+ // - `?refresh_file=<path>` — a file or directory path (raw or
1001
+ // `file://`). Refreshes the local collections whose scanned
1002
+ // directory overlaps it (so an added / renamed / deleted file is
1003
+ // noticed) plus any individually-added local script it names.
1004
+ // See src/extension/refresh-targets.ts for the selection rules.
1005
+ //
1006
+ // `?add=<path-or-url>` does the equivalent of typing the value
1007
+ // into the Add field and clicking Add. Validates the input;
1008
+ // shows an error if it doesn't look like an absolute path or URL.
1009
+ // On success or duplicate, jumps to the resulting collection section.
1010
+ // Lets the docs/redirect-page.md deep link route here from anywhere
1011
+ // on the web (see docs/deep-link.md).
1012
+ async function bootstrap() {
1013
+ // The GitHub auth token hydrated before anything here reads GitHub.
1014
+ // Released even if anything above the render throws: every repaint
1015
+ // waits on this, and the flow handlers await a repaint inside a `try`
1016
+ // whose `finally` resets their button — so a deferred left unresolved
1017
+ // would hang the Add form and the Refresh buttons with no way back
1018
+ // short of a reload. A repaint is the recovery from a half-built page,
1019
+ // so it must never be the thing left waiting.
1020
+ try {
1021
+ await githubTokenReady;
1022
+ // Same reasoning for the configured local-file roots: the icon
1023
+ // slots resolve stand-ins synchronously during render, so the
1024
+ // setting has to be in hand before the first paint.
1025
+ await localRootsReady();
1026
+ await render();
1027
+ }
1028
+ finally {
1029
+ resolveInitialRender();
1030
+ }
1031
+ // Re-trigger any `#collection-<id>` hash scroll after the async
1032
+ // render. The browser's initial jump fires immediately on page load,
1033
+ // before our render() finishes building the sections, so it finds no
1034
+ // matching element and gives up. Look at the hash again now that
1035
+ // every section is in the DOM and scroll the matching one into view.
1036
+ // Triggered for any hash matching the collection-anchor shape.
1037
+ scrollToHashCollectionIfPresent();
1038
+ if (location.hash.startsWith(`#${EDIT_COLLECTION_HASH_PREFIX}`))
1039
+ void focusHashTarget(location.hash.slice(1));
1040
+ const params = new URLSearchParams(location.search);
1041
+ // First-run seeding. The SW opens this page with `?first_run` once on
1042
+ // install (see background.ts); only that auto-opened page seeds, so an
1043
+ // ordinary visit never adds the defaults. The flag is re-checked here
1044
+ // so a manual reload of the same URL can't seed twice.
1045
+ if (params.has('first_run')) {
1046
+ const done = await isFirstRunComplete();
1047
+ console.log(`[SourceMonkey] collections page opened with ?first_run (firstRunComplete=${done})`);
1048
+ if (!done)
1049
+ await seedDefaultCollectionsViaUi();
1050
+ else
1051
+ console.log('[SourceMonkey] first run already complete; skipping seed');
1052
+ scrubQueryParams(['first_run']);
1053
+ }
1054
+ if (params.has('refresh')) {
1055
+ await onRefreshAll();
1056
+ scrubQueryParams(['refresh']);
1057
+ }
1058
+ const addArg = params.get('add');
1059
+ if (addArg !== null) {
1060
+ await handleAddParam(addArg);
1061
+ }
1062
+ // After `?add`, so a collection added by the same URL is included —
1063
+ // and so a rejected `?refresh_file` message is the one left standing
1064
+ // on the page's single error line.
1065
+ if (params.has('refresh_local')) {
1066
+ await onRefreshSubset(localRefreshTargets(await readCollectionScripts()));
1067
+ scrubQueryParams(['refresh_local']);
1068
+ }
1069
+ const refreshFileArg = params.get('refresh_file');
1070
+ if (refreshFileArg !== null) {
1071
+ if (await handleRefreshFileParam(refreshFileArg))
1072
+ scrubQueryParams(['refresh_file']);
1073
+ }
1074
+ }
1075
+ // First-run seeding, driven from the page so the user watches the
1076
+ // familiar flow: each default collection's section appears, populated,
1077
+ // as it loads. `addCollection` fetches each collection's scripts as
1078
+ // part of the add, so no follow-up Refresh is needed. Marks first-run
1079
+ // complete at the end (even if a collection failed) so it never repeats.
1080
+ async function seedDefaultCollectionsViaUi() {
1081
+ // Best-effort + run-once: mark complete in a finally so a failure on
1082
+ // one default (network down, bad URL) neither aborts the others nor
1083
+ // leaves the flag unset (which can't auto-retry — onInstalled fires
1084
+ // only once, so a re-open wouldn't carry ?first_run). A failed
1085
+ // collection still leaves a section with its Refresh button for
1086
+ // manual recovery.
1087
+ console.log('[SourceMonkey] first run: seeding default collections', [...DEFAULT_COLLECTIONS]);
1088
+ try {
1089
+ // Skip exact-repeat entries up front; addCollection also dedupes by
1090
+ // canonical collection path (so two spellings of the same
1091
+ // collection still yield one), this just avoids a pointless
1092
+ // duplicate-add round-trip for a byte-identical repeat. `jump:
1093
+ // false` so adding several in a row doesn't scroll-dance through
1094
+ // each new section.
1095
+ const seen = new Set();
1096
+ for (const raw of DEFAULT_COLLECTIONS) {
1097
+ const path = raw.trim();
1098
+ if (!path || seen.has(path))
1099
+ continue;
1100
+ seen.add(path);
1101
+ console.log('[SourceMonkey] first run: adding collection', path);
1102
+ addInput.value = path;
1103
+ try {
1104
+ await seedCollection(path);
1105
+ }
1106
+ catch (err) {
1107
+ console.info('[SourceMonkey] first-run add failed', path, err);
1108
+ }
1109
+ }
1110
+ }
1111
+ finally {
1112
+ // Clear the Add field — seedCollection clears it on success, but a
1113
+ // failed add leaves the last attempted path sitting there.
1114
+ addInput.value = '';
1115
+ console.log('[SourceMonkey] first run: seeding complete; marking firstRunComplete=true');
1116
+ // Caught here so a failure can't replace the outcome of the seeding
1117
+ // above. With the flag unset, a later install event opens the page
1118
+ // with `?first_run` again, and the add dedupes what is already
1119
+ // there.
1120
+ await markFirstRunComplete().catch((err) => {
1121
+ console.info('[SourceMonkey] could not record that first-run seeding is done:', describeError(err));
1122
+ });
1123
+ }
1124
+ }
1125
+ // Show what an incoming link asked for, in a tab that is already open
1126
+ // and already scrolled somewhere else. The empty hash is a link to the
1127
+ // page as a whole, which means the top.
1128
+ //
1129
+ // The URL is rewritten to match so a reload, a share, or the back
1130
+ // button lands where the user is now looking — the same bargain
1131
+ // `jumpToCollection` makes after an add.
1132
+ async function focusHashTarget(hash) {
1133
+ // Both, not just the first render: a repaint empties the container
1134
+ // and refills it across several awaits, and a message that landed
1135
+ // mid-repaint would look for a section that is not in the document
1136
+ // yet and silently give up.
1137
+ await initialRenderDone;
1138
+ await repaintChain;
1139
+ if (!hash) {
1140
+ history.replaceState(null, '', location.pathname + location.search);
1141
+ scrollTo({ top: 0, behavior: 'smooth' });
1142
+ return;
1143
+ }
1144
+ // `edit-collection-<id>` (the popup's "Collection options"): land on
1145
+ // the collection and open its edit dialog. The URL keeps the plain
1146
+ // collection anchor, so a reload doesn't reopen the dialog.
1147
+ const editId = hash.startsWith(EDIT_COLLECTION_HASH_PREFIX) ? hash.slice(EDIT_COLLECTION_HASH_PREFIX.length) : null;
1148
+ const anchor = editId !== null ? collectionAnchorId(editId) : hash;
1149
+ const section = document.getElementById(anchor);
1150
+ if (!section)
1151
+ return;
1152
+ history.replaceState(null, '', `#${anchor}`);
1153
+ section.scrollIntoView({ behavior: 'smooth', block: 'start' });
1154
+ if (editId !== null)
1155
+ await onEditCollection(editId);
1156
+ }
1157
+ // If the URL hash points at a rendered collection section, scroll it
1158
+ // into view. No-op otherwise; safe to call any time after render().
1159
+ function scrollToHashCollectionIfPresent() {
1160
+ if (!location.hash.startsWith('#collection-'))
1161
+ return;
1162
+ const id = location.hash.slice(1);
1163
+ const section = document.getElementById(id);
1164
+ if (!section)
1165
+ return;
1166
+ section.scrollIntoView({ block: 'start' });
1167
+ }
1168
+ // Drive the Add flow from a `?add=` query param. Pre-fills the form
1169
+ // field (so the user sees what was attempted) and runs the same flow
1170
+ // as the manual Add button — a `.user.js` opens the install page in a
1171
+ // new tab, clearing the field again as it goes; anything else opens the
1172
+ // create dialog prefilled. The URL is scrubbed
1173
+ // once the value was consumed; a cancelled dialog leaves it in place,
1174
+ // so the user can copy/share/edit it.
1175
+ async function handleAddParam(raw) {
1176
+ addInput.value = raw;
1177
+ const ok = await onAddViaDialog(raw, true);
1178
+ if (ok)
1179
+ scrubQueryParams(['add']);
1180
+ }
1181
+ // Every collection paired with the scripts it holds, which is what the
1182
+ // `refresh-targets` selectors read. One storage scan for all the
1183
+ // scripts rather than a per-collection read.
1184
+ async function readCollectionScripts() {
1185
+ const [collections, scripts] = await Promise.all([getCollections(), getAllScriptMetas()]);
1186
+ const byCollection = new Map();
1187
+ for (const script of scripts) {
1188
+ const list = byCollection.get(script.collectionId);
1189
+ if (list)
1190
+ list.push(script);
1191
+ else
1192
+ byCollection.set(script.collectionId, [script]);
1193
+ }
1194
+ return collections.map((collection) => ({
1195
+ collection,
1196
+ scripts: byCollection.get(collection.id) ?? [],
1197
+ }));
1198
+ }
1199
+ // Drive a targeted refresh from a `?refresh_file=` query param.
1200
+ // Returns whether the param was consumed: a value we couldn't act on —
1201
+ // wrong shape, or naming somewhere no local collection or script covers
1202
+ // — stays in the URL (like a rejected `?add`) with the reason on the
1203
+ // page, so the caller can see what it asked for and fix it.
1204
+ async function handleRefreshFileParam(raw) {
1205
+ const path = raw.trim();
1206
+ const problem = refreshPathProblem(path);
1207
+ if (problem !== null) {
1208
+ showPageError(`${REFRESH_FAILED_PREFIX}${problem}`);
1209
+ return false;
1210
+ }
1211
+ const targets = pathRefreshTargets(await readCollectionScripts(), path);
1212
+ if (targets.collectionIds.length === 0 && targets.scripts.length === 0) {
1213
+ showPageError(`${REFRESH_FAILED_PREFIX}No local collection or script covers ${path}`);
1214
+ return false;
1215
+ }
1216
+ showPageError(null);
1217
+ await onRefreshSubset(targets);
1218
+ return true;
1219
+ }
1220
+ // Remove the given query params from the current URL via
1221
+ // replaceState. Preserves the rest of the URL (including any hash
1222
+ // the add flow may have just written) so the user can reload without
1223
+ // re-triggering a consumed action.
1224
+ function scrubQueryParams(names) {
1225
+ const url = new URL(location.href);
1226
+ let changed = false;
1227
+ for (const name of names) {
1228
+ if (url.searchParams.has(name)) {
1229
+ url.searchParams.delete(name);
1230
+ changed = true;
1231
+ }
1232
+ }
1233
+ if (changed)
1234
+ history.replaceState(null, '', url.toString());
1235
+ }
1236
+ // Where local copies live, from the `smDownloadsCopies` setting. Null
1237
+ // until the first copy ever has been made, and on a page that failed to
1238
+ // read it — in both cases no row offers a file deletion, which is the
1239
+ // safe answer. Seeded by `render`.
1240
+ let currentCopiesSetting = null;
1241
+ // ---------- Initial render ----------
1242
+ async function render() {
1243
+ bannerSlot.textContent = '';
1244
+ collectionsEl.textContent = '';
1245
+ // Seed the status maps FIRST, before anything that can throw. Every
1246
+ // Status cell is drawn through them, and a repaint after a failed
1247
+ // render rebuilds the sections but does not re-seed these — so seeding
1248
+ // late would leave a recovered page painting every row without its
1249
+ // WARNING / ERROR / REJECTED promotion until an unrelated session
1250
+ // event happened along.
1251
+ currentLogLevels = await getAllMaxLogLevels();
1252
+ currentRejections = await readRejections();
1253
+ currentRefreshErrors = await readRefreshErrors();
1254
+ // Which directory holds the local copies, so every script row can be
1255
+ // asked — synchronously, while it is drawn — whether its file is one
1256
+ // SourceMonkey can delete. Re-read on every render, since a copy made
1257
+ // in another tab can be what created the setting.
1258
+ currentCopiesSetting = await readCopiesSetting();
1259
+ // Surface the file:// access state up-front. Without the toggle on
1260
+ // chrome://extensions, every fetch we try will fail.
1261
+ if (!(await isFileSchemeAccessAllowed())) {
1262
+ bannerSlot.appendChild(renderFileAccessBanner());
1263
+ }
1264
+ // The User Scripts API requires the user to flip the "Allow User
1265
+ // Scripts" toggle on chrome://extensions. Until they do, every
1266
+ // registration call throws — meaning no scripts actually inject even
1267
+ // though the badge still counts matches. Other failures (revoked permission, etc.)
1268
+ // surface their raw message so the user has something to grep for.
1269
+ await syncUserScriptsBanner();
1270
+ let collections = await getCollections();
1271
+ // One-shot backfill so older collection rows (pre-displayName) pick
1272
+ // up an inferred label on first view after upgrade. Asked of the
1273
+ // worker only when a row has none.
1274
+ if (collections.some(needsDisplayName)) {
1275
+ try {
1276
+ await backfillCollectionDisplayNames();
1277
+ collections = await getCollections();
1278
+ }
1279
+ catch (err) {
1280
+ // Cosmetic: the sections draw with the names they have.
1281
+ console.info('[SourceMonkey] display-name backfill failed', describeError(err));
1282
+ }
1283
+ }
1284
+ // Last of the three banners, because it is the most situational: it
1285
+ // appears only once a refresh has actually failed to save, where the
1286
+ // other two describe a setting that is wrong right now. It comes and
1287
+ // goes WITHOUT a reload (a failed refresh raises it, the next
1288
+ // successful one clears it), so it owns a sync of its own that the
1289
+ // repaint calls too.
1290
+ await syncStorageBanner(new Set(collections.map((s) => s.id)));
1291
+ const allScripts = await getAllScriptMetas();
1292
+ // Repair any collection whose `scriptIds` disagrees with the script
1293
+ // rows themselves. This is the page where the damage shows (a script
1294
+ // that runs but is listed nowhere). The worker does it from its own
1295
+ // read of the rows, and isn't waited for: a repair it writes repaints
1296
+ // this page like any other change, and failing is not worth
1297
+ // abandoning the render over.
1298
+ reconcileScriptMembership().catch((err) => {
1299
+ console.info('[SourceMonkey] membership reconcile failed', err);
1300
+ });
1301
+ if (collections.length === 0) {
1302
+ collectionsEl.appendChild(emptyPlaceholder());
1303
+ return;
1304
+ }
1305
+ // See `repaintFromStorage`.
1306
+ await preloadSiteIcons(allScripts);
1307
+ for (const collection of collections) {
1308
+ collectionsEl.appendChild(await renderCollectionSection(collection));
1309
+ }
1310
+ refreshMoveButtons();
1311
+ markDiskChanges();
1312
+ }
1313
+ function emptyPlaceholder() {
1314
+ const p = document.createElement('p');
1315
+ p.className = 'placeholder empty-placeholder';
1316
+ p.textContent = 'No collections yet. Add one above.';
1317
+ return p;
1318
+ }
1319
+ // ---------- Per-section build ----------
1320
+ async function renderCollectionSection(collection) {
1321
+ const collectionOn = isCollectionEnabled(collection);
1322
+ const section = document.createElement('section');
1323
+ section.className = 'collection';
1324
+ section.dataset.collectionId = collection.id;
1325
+ // For `markDiskChanges`, which works from the DOM after a paint.
1326
+ section.dataset.collectionPath = collection.path;
1327
+ syncManifestStatDataset(section, collection);
1328
+ // Stable per-collection anchor; lets deep links (and the post-add
1329
+ // `jumpToCollection` helper) scroll directly to this section.
1330
+ section.id = collectionAnchorId(collection.id);
1331
+ if (!collectionOn)
1332
+ section.classList.add('disabled');
1333
+ const header = document.createElement('div');
1334
+ header.className = 'collection-header';
1335
+ // Per-collection enable toggle sits left of the path so it visually
1336
+ // groups with the directory it controls and aligns with the
1337
+ // per-script toggle column below.
1338
+ const enableLabel = makeToggle(collectionOn, false, (next, label, solo) => void onToggleCollection(collection.id, next, label, solo).catch(onEnableFailure), 'Enable scripts under this collection');
1339
+ enableLabel.classList.add('collection-toggle');
1340
+ header.appendChild(enableLabel);
1341
+ const heading = document.createElement('h2');
1342
+ heading.appendChild(makeCollectionTypeBubble(collection.path));
1343
+ const nameEl = document.createElement('span');
1344
+ nameEl.className = 'collection-display-name';
1345
+ appendWithSlashBreaks(nameEl, effectiveDisplayName(collection));
1346
+ nameEl.dataset.displayName = effectiveDisplayName(collection);
1347
+ // Tooltip surfaces the raw stored path so the user can still see the
1348
+ // canonical URL / fs path behind a shortened display name. Container
1349
+ // collections have a synthetic identifier as their path, which would
1350
+ // leak `container:file` etc. — skip the tooltip in that case.
1351
+ if (!isContainerCollection(collection.path))
1352
+ nameEl.title = collection.path;
1353
+ heading.appendChild(nameEl);
1354
+ header.appendChild(heading);
1355
+ const buttons = document.createElement('div');
1356
+ buttons.className = 'collection-buttons';
1357
+ buttons.appendChild(makeMoveButton('up', collection.id));
1358
+ buttons.appendChild(makeMoveButton('down', collection.id));
1359
+ // The same trash can the script rows carry, for the same reason: one
1360
+ // removal action must not look like two different controls on one
1361
+ // page. `record` — a collection's removal drops SourceMonkey's rows,
1362
+ // never the files behind them.
1363
+ const removeBtn = document.createElement('button');
1364
+ removeBtn.type = 'button';
1365
+ removeBtn.className = 'remove-script';
1366
+ setDeleteIcon(removeBtn, 'record');
1367
+ removeBtn.title = 'Remove this collection';
1368
+ removeBtn.setAttribute('aria-label', 'Remove this collection');
1369
+ removeBtn.addEventListener('click', () => {
1370
+ onRemoveCollection(collection.id, section, removeBtn).catch((err) => {
1371
+ console.info('[SourceMonkey] remove collection failed', describeError(err));
1372
+ });
1373
+ });
1374
+ buttons.appendChild(removeBtn);
1375
+ // None for an sm-dev test collection, which its run names.
1376
+ if (!isSmDevCollection(collection.path)) {
1377
+ const editBtn = document.createElement('button');
1378
+ editBtn.className = 'edit-collection';
1379
+ // A gear, not a pencil: the dialog behind it is mostly options, and
1380
+ // a pencil would promise editing the scripts themselves.
1381
+ setGlyphButton(editBtn, '#sm-gear', 'Rename this collection or change its options');
1382
+ editBtn.addEventListener('click', () => void onEditCollection(collection.id));
1383
+ buttons.appendChild(editBtn);
1384
+ }
1385
+ // Rightmost, matching Refresh All at the top of the page: the same
1386
+ // action in the same place, whichever row of the page you are on.
1387
+ const refreshBtn = document.createElement('button');
1388
+ refreshBtn.className = 'refresh-collection';
1389
+ setGlyphButton(refreshBtn, '#sm-refresh', REFRESH_COLLECTION_LABELS.idle);
1390
+ refreshBtn.addEventListener('click', () => void onRefreshCollection(collection.id, refreshBtn));
1391
+ buttons.appendChild(refreshBtn);
1392
+ // Actions on this collection. None for an sm-dev test collection,
1393
+ // which the export leaves out.
1394
+ if (!isSmDevCollection(collection.path)) {
1395
+ const menuBtn = moreMenuButton('More actions for this collection');
1396
+ menuBtn.classList.add('collection-menu');
1397
+ menuBtn.title = 'More actions for this collection';
1398
+ menuBtn.addEventListener('click', () => openMenu(menuBtn, [
1399
+ {
1400
+ label: 'Export this collection\u2026',
1401
+ run: () => openExportDialog({ scope: { collectionId: collection.id }, collectionName: effectiveDisplayName(collection) }),
1402
+ },
1403
+ ]));
1404
+ buttons.appendChild(menuBtn);
1405
+ }
1406
+ header.appendChild(buttons);
1407
+ section.appendChild(header);
1408
+ // Both can be present: a collection can fail to save on one refresh
1409
+ // and fail to scan on the next. They describe different attempts and
1410
+ // different fixes, so neither replaces the other.
1411
+ const scanError = scanErrorText(collection);
1412
+ if (scanError)
1413
+ section.appendChild(makeSectionMessage('scan', scanError));
1414
+ const refreshError = refreshErrorText(collection.id);
1415
+ if (refreshError)
1416
+ section.appendChild(makeSectionMessage('refresh', refreshError));
1417
+ section.appendChild(await renderScriptBlock(collection, collectionOn));
1418
+ return section;
1419
+ }
1420
+ // The section's could-not-save line, or '' when this collection's last
1421
+ // refresh stored cleanly. Reads the page-wide map rather than taking a
1422
+ // parameter so the repaint path and the initial render can't disagree.
1423
+ function refreshErrorText(collectionId) {
1424
+ const reason = currentRefreshErrors[collectionId];
1425
+ return reason ? describeRefreshError(reason) : '';
1426
+ }
1427
+ // The section's scan-failure line, or '' when the collection scanned
1428
+ // fine. Shared with the repaint so the two can't render the same
1429
+ // failure two different ways.
1430
+ //
1431
+ // "Scan" is the name of our pipeline stage, not something the page ever
1432
+ // shows — what the reader sees is a collection that didn't load.
1433
+ // Phrased to avoid echoing what follows: most `scanError` values
1434
+ // already open with `Could not read <url>: …`, so a `Could not …:`
1435
+ // prefix would say "could not" twice in a row.
1436
+ function scanErrorText(collection) {
1437
+ return collection.scanError ? `This collection did not load: ${collection.scanError}` : '';
1438
+ }
1439
+ // The section's script list: a table, or the message that stands in for
1440
+ // it when the collection has no scripts. Both carry `.script-block` so
1441
+ // a repaint can find whichever is present and swap one shape for the
1442
+ // other without disturbing the header above it.
1443
+ async function renderScriptBlock(collection, collectionOn) {
1444
+ const block = await renderScriptTable(collection, collectionOn);
1445
+ block.classList.add('script-block');
1446
+ return block;
1447
+ }
1448
+ async function renderScriptTable(collection, collectionOn) {
1449
+ const data = await getCollectionWithScriptMeta(collection.id);
1450
+ const inContainer = isContainerCollection(collection.path);
1451
+ const scripts = sortedByShownName(data?.scripts ?? [], inContainer);
1452
+ const localFiles = localFileScope(collection);
1453
+ if (scripts.length === 0) {
1454
+ return makeMessage('empty', inContainer
1455
+ ? 'No scripts added yet'
1456
+ : 'No *.user.js files found');
1457
+ }
1458
+ const allOn = allOnByIdentity(scripts.map((s) => ({ identity: rowIdentity(s), on: isScriptEnabled(s) })));
1459
+ const table = document.createElement('table');
1460
+ table.className = 'script-table';
1461
+ const thead = document.createElement('thead');
1462
+ const headRow = document.createElement('tr');
1463
+ const bulkHead = document.createElement('th');
1464
+ bulkHead.className = 'toggle-cell';
1465
+ const bulkBtn = makeBulkToggle(allOn, !collectionOn, (solo) => void onBulkToggleScripts(collection.id, bulkBtn, solo).catch(onEnableFailure), bulkTitle(collectionOn, allOn));
1466
+ bulkHead.appendChild(bulkBtn);
1467
+ headRow.appendChild(bulkHead);
1468
+ for (const label of ['Name', 'Size', 'Status']) {
1469
+ const th = document.createElement('th');
1470
+ th.textContent = label;
1471
+ // Right-aligned with its cells, so the numbers line up.
1472
+ if (label === 'Size')
1473
+ th.className = 'size-cell';
1474
+ headRow.appendChild(th);
1475
+ if (label === 'Name' && inContainer) {
1476
+ // A scripts collection can mix local, web, and GitHub scripts,
1477
+ // so each row says where its own script loads from.
1478
+ const schemeTh = document.createElement('th');
1479
+ schemeTh.setAttribute('aria-label', 'Source');
1480
+ headRow.appendChild(schemeTh);
1481
+ }
1482
+ }
1483
+ // Trailing column carries the per-row remove button: every container
1484
+ // script has one, and outside a container a local copy has one that
1485
+ // deletes its file too. Empty text would announce as "blank column
1486
+ // header"; an aria-label tells screen-readers what it's for.
1487
+ const withActions = inContainer || scripts.some((s) => isDeletableCopy(s, collection.path));
1488
+ if (withActions) {
1489
+ const actionsTh = document.createElement('th');
1490
+ actionsTh.setAttribute('aria-label', 'Actions');
1491
+ headRow.appendChild(actionsTh);
1492
+ }
1493
+ thead.appendChild(headRow);
1494
+ table.appendChild(thead);
1495
+ const tbody = document.createElement('tbody');
1496
+ const settled = [];
1497
+ for (const script of scripts) {
1498
+ const { row, iconCount } = renderScriptRow(script, collectionOn, collection.path, localFiles, withActions);
1499
+ tbody.appendChild(row);
1500
+ settled.push(iconCount);
1501
+ }
1502
+ table.appendChild(tbody);
1503
+ fitIconColumns(table, settled);
1504
+ return table;
1505
+ }
1506
+ function renderScriptRow(script, collectionOn, collectionPath, localFiles,
1507
+ // The table has a trailing action column. Every row in such a table
1508
+ // gets the cell, empty or not, or the columns stop lining up.
1509
+ withActions) {
1510
+ const inContainer = isContainerCollection(collectionPath);
1511
+ const scriptOn = isScriptEnabled(script);
1512
+ const tr = document.createElement('tr');
1513
+ tr.dataset.scriptId = script.id;
1514
+ tr.dataset.collectionId = script.collectionId;
1515
+ tr.dataset.identity = rowIdentity(script);
1516
+ // Stash the persisted status + errorMessage on the row so
1517
+ // `applyStatusCellsToDom` can repaint the Status cell after a
1518
+ // chrome.storage.session change without re-querying the store.
1519
+ const { errorMessage } = applyScriptRowDatasets(tr, script);
1520
+ if (!collectionOn || !scriptOn)
1521
+ tr.classList.add('disabled');
1522
+ const toggleCell = document.createElement('td');
1523
+ toggleCell.className = 'toggle-cell';
1524
+ toggleCell.appendChild(makeToggle(scriptOn, !collectionOn, // disabled (greyed out) when the parent collection is off
1525
+ (next, label, solo) => void onToggleScript(script.id, next, label, solo).catch(onEnableFailure), collectionOn ? ENABLE_TITLE : COLLECTION_OFF_TITLE));
1526
+ tr.appendChild(toggleCell);
1527
+ const nameCell = document.createElement('td');
1528
+ nameCell.classList.add('name-cell');
1529
+ const iconCount = fillNameCell(nameCell, script, collectionPath, localFiles);
1530
+ tr.appendChild(nameCell);
1531
+ // For column parity with the live table. A container collection can't
1532
+ // actually reach here — its scan is built from the scripts it holds,
1533
+ // so nothing in one ever reads as vanished — but a table whose rows
1534
+ // disagree about column count stops lining up.
1535
+ if (inContainer) {
1536
+ const schemeCell = document.createElement('td');
1537
+ schemeCell.className = 'scheme-cell';
1538
+ schemeCell.appendChild(makeSchemeChip(script.relativePath));
1539
+ tr.appendChild(schemeCell);
1540
+ }
1541
+ const sizeCell = document.createElement('td');
1542
+ sizeCell.className = 'size-cell';
1543
+ sizeCell.textContent = formatScriptSize(script);
1544
+ tr.appendChild(sizeCell);
1545
+ const statusCell = document.createElement('td');
1546
+ paintStatusCell(statusCell, script.id, script.status, errorMessage, script.refreshError);
1547
+ tr.appendChild(statusCell);
1548
+ // Container rows always offer a remove button (every script inside a
1549
+ // container was added one-by-one and can be taken back out the same
1550
+ // way). A local copy offers one too, which deletes its file as well —
1551
+ // the one place SourceMonkey can. Every other row is the collection's
1552
+ // to add or drop on refresh.
1553
+ if (withActions) {
1554
+ const actionCell = document.createElement('td');
1555
+ actionCell.className = 'action-cell';
1556
+ if (inContainer) {
1557
+ const removeBtn = document.createElement('button');
1558
+ removeBtn.type = 'button';
1559
+ removeBtn.className = 'remove-script';
1560
+ setDeleteIcon(removeBtn, 'record');
1561
+ removeBtn.title = 'Remove this script';
1562
+ removeBtn.setAttribute('aria-label', 'Remove this script');
1563
+ removeBtn.addEventListener('click', () => {
1564
+ void onRemoveScript(script.id, script.collectionId, removeBtn);
1565
+ });
1566
+ actionCell.appendChild(removeBtn);
1567
+ }
1568
+ else if (isDeletableCopy(script, localFiles.collectionPath)) {
1569
+ actionCell.appendChild(makeDeleteCopyButton(script));
1570
+ }
1571
+ tr.appendChild(actionCell);
1572
+ }
1573
+ return { row: tr, iconCount };
1574
+ }
1575
+ // The icon column of a script table is as wide as its widest row —
1576
+ // the number of site icons that row shows. Stored as a CSS custom
1577
+ // property the stylesheet's slot width and hanging indent both read.
1578
+ function setIconColumns(table, count) {
1579
+ table.style.setProperty('--icon-cols', String(count));
1580
+ }
1581
+ // The most icons any slot in this table currently holds. A slot whose
1582
+ // favicons are still loading counts at its estimate — its icons are
1583
+ // already there as globes.
1584
+ function widestIconSlot(table) {
1585
+ let widest = 1;
1586
+ for (const slot of table.querySelectorAll('td.name-cell .site-icon')) {
1587
+ widest = Math.max(widest, slot.childElementCount);
1588
+ }
1589
+ return widest;
1590
+ }
1591
+ // Fit the icon column to the rows now in the table, twice: right away,
1592
+ // from the header-derived estimates the fresh slots hold, so nothing
1593
+ // overflows while favicons load; and again once `settling` (the
1594
+ // rebuilt slots' promises) has resolved, because sites sharing one
1595
+ // favicon collapse to a single icon and the column may narrow. One
1596
+ // shift per pass, not one per row. Measured from the rows on screen
1597
+ // rather than from what the promises carry, so rows that came or went
1598
+ // in another pass meanwhile are counted too.
1599
+ function fitIconColumns(table, settling) {
1600
+ setIconColumns(table, widestIconSlot(table));
1601
+ if (settling.length === 0)
1602
+ return;
1603
+ // No isConnected guard: a section is built detached and attached
1604
+ // later, and a table whose favicons all came from cache can settle
1605
+ // before that. Styling a detached table is harmless. `allSettled` so
1606
+ // one slot's failure can't skip the fit for the rest.
1607
+ void Promise.allSettled(settling).then(() => setIconColumns(table, widestIconSlot(table)));
1608
+ }
1609
+ // Build (or rebuild) the Name cell's contents: favicon slot and the
1610
+ // linked name. Shared with the live in-place
1611
+ // row update, which re-runs it when a refresh changed something this
1612
+ // cell shows — otherwise a script whose `@match` or `@description` was
1613
+ // edited keeps the old site's favicon and the old hover text until the
1614
+ // section happens to be rebuilt for an unrelated reason.
1615
+ function fillNameCell(nameCell, script, collectionPath,
1616
+ // Where this script's collection allows local file overrides to come
1617
+ // from. Threaded alongside the path — both are per-collection facts
1618
+ // the row needs and neither is worth re-loading the collection row
1619
+ // for.
1620
+ localFiles) {
1621
+ const inContainer = isContainerCollection(collectionPath);
1622
+ nameCell.textContent = '';
1623
+ // Slot for the favicons: a fixed-width container so the name
1624
+ // text always starts at the same x-position. renderSiteIconInto
1625
+ // synchronously paints one globe placeholder (clickable when the
1626
+ // script's @match yields a usable origin), then fills in each site's
1627
+ // favicon, with duplicates merged, once the fetches resolve.
1628
+ const iconSlot = document.createElement('span');
1629
+ iconSlot.className = 'site-icon';
1630
+ nameCell.appendChild(iconSlot);
1631
+ const iconCount = renderSiteIconInto(iconSlot, script, localSearchRoots(script.fileUrl, localFiles));
1632
+ const a = document.createElement('a');
1633
+ // Slash-aware appender (not textContent) so a long name wraps at `/`
1634
+ // boundaries before breaking mid-segment, matching the collection
1635
+ // heading and the viewer's Location row.
1636
+ appendWithSlashBreaks(a, nameCellText(script, inContainer));
1637
+ // Hovering the name surfaces where the script came from and what it
1638
+ // says it does. Scoped to the anchor so the Size / Status cells
1639
+ // (which carry their own meaning) don't inherit it as a row-wide
1640
+ // tooltip.
1641
+ a.title = nameCellTooltip(script);
1642
+ a.href = viewerUrl(script.id);
1643
+ a.target = '_blank';
1644
+ a.rel = 'noopener noreferrer';
1645
+ // A local file that has been deleted or changed gets a chip after
1646
+ // the name, painted by `markDiskChanges` once the section is on
1647
+ // screen.
1648
+ const tag = scriptFileTag(script, collectionPath);
1649
+ if (tag)
1650
+ tagLocalFile(a, tag);
1651
+ nameCell.appendChild(a);
1652
+ // Record what this cell was drawn from, so the next repaint can skip
1653
+ // it unless one of those inputs moved.
1654
+ nameCellKeys.set(script.id, nameCellKey(script, localFiles));
1655
+ shownNames.set(script.id, nameCellText(script, inContainer));
1656
+ return iconCount;
1657
+ }
1658
+ // Text in the Name column: the script's own `@name`, falling back to
1659
+ // its location when it declares none (or hasn't parsed far enough to
1660
+ // have a header at all).
1661
+ //
1662
+ // The fallback is shortened for container scripts, whose `relativePath`
1663
+ // is the whole URL — a column that has to stay scannable at table width
1664
+ // can't carry one. A manifest entry may itself be an absolute URL, so
1665
+ // the non-container branch goes through displayLocation too — otherwise
1666
+ // the same script reads encoded here and decoded in the viewer. Local
1667
+ // directory entries have no scheme and pass through untouched.
1668
+ //
1669
+ // Only the column shortens. The tooltip below always gives the location
1670
+ // in full, which is the point of having it there.
1671
+ function nameCellText(script, inContainer) {
1672
+ const location = inContainer
1673
+ ? scriptBasename(script.relativePath)
1674
+ : displayLocation(script.relativePath);
1675
+ return scriptDisplayName(script, location);
1676
+ }
1677
+ // A collection's rows are listed by the name they show, not stored
1678
+ // order: that order is scan or install order, which nothing on the page
1679
+ // reveals. Ties (two scripts with the same `@name`) fall back to the
1680
+ // location so the order stays stable across repaints.
1681
+ function sortedByShownName(scripts, inContainer) {
1682
+ return scripts
1683
+ .map((script) => ({ script, name: nameCellText(script, inContainer) }))
1684
+ .sort((a, b) => compareScriptNames(a.name, b.name)
1685
+ || compareScriptNames(a.script.relativePath, b.script.relativePath))
1686
+ .map(({ script }) => script);
1687
+ }
1688
+ // Tooltip on the name link: the whole location first — the column no
1689
+ // longer shows it, and it's what tells two same-named scripts apart —
1690
+ // then a blank line, then the parsed `@description`, or "No description"
1691
+ // when the script declares none.
1692
+ function nameCellTooltip(script) {
1693
+ const desc = script.header ? getValue(script.header, 'description') : undefined;
1694
+ return `${displayLocation(script.relativePath)}\n\n${desc?.trim() || 'No description'}`;
1695
+ }
1696
+ // True when this script is a local copy whose file SourceMonkey can
1697
+ // delete — which takes its collection as well as the row (see
1698
+ // `deletableCopyPath`). Reads the setting the render seeded, so it can
1699
+ // be asked while a row is being built.
1700
+ function isDeletableCopy(script, collectionPath) {
1701
+ const collection = collectionPath === undefined ? null : { path: collectionPath };
1702
+ return deletableCopyPath(script, collection, currentCopiesSetting) !== null;
1703
+ }
1704
+ // The trash can on a local copy's row. Unlike the other two, this one
1705
+ // deletes the file — hence the badged glyph and the red resting colour
1706
+ // (src/extension/delete-icon.ts). The dialog says so too, names the path, and
1707
+ // offers the dependency files that came with it (src/extension/delete-dialog.ts).
1708
+ function makeDeleteCopyButton(script) {
1709
+ const btn = document.createElement('button');
1710
+ btn.type = 'button';
1711
+ btn.className = 'remove-script';
1712
+ setDeleteIcon(btn, 'file');
1713
+ btn.title = 'Delete local script file';
1714
+ btn.setAttribute('aria-label', 'Delete local script file');
1715
+ btn.addEventListener('click', () => void onDeleteCopy(script, btn));
1716
+ return btn;
1717
+ }
1718
+ async function onDeleteCopy(script, btn) {
1719
+ const result = await openDeleteDialog({ script });
1720
+ if (!result)
1721
+ return;
1722
+ showPageError(null);
1723
+ btn.disabled = true;
1724
+ // The record goes with the file. Not a refresh of the collection: it
1725
+ // would drop the row too, but at the cost of a scan, and here we know
1726
+ // exactly why the file is gone.
1727
+ //
1728
+ // Its own handler rather than `onRemoveScript`, which reports a
1729
+ // failure to the console: by the time this runs the FILE is already
1730
+ // gone, so a row that stays needs to say why on the page. Both of
1731
+ // the reports below are things only this path can produce.
1732
+ try {
1733
+ await removeScript(result.scriptId);
1734
+ }
1735
+ catch (err) {
1736
+ showPageError(`The file was deleted, but its record could not be removed: ${describeError(err)}`);
1737
+ btn.disabled = false;
1738
+ return;
1739
+ }
1740
+ // Files left on disk after the script itself went. The same report
1741
+ // the viewer gives, so the two surfaces say the same thing about the
1742
+ // same deletion.
1743
+ if (result.dependencyErrors.length > 0)
1744
+ showPageError(result.dependencyErrors.join(' '));
1745
+ await repaintPage();
1746
+ }
1747
+ async function onRemoveScript(scriptId, _collectionId, btn) {
1748
+ btn.disabled = true;
1749
+ try {
1750
+ await removeScript(scriptId);
1751
+ // Write, then repaint — the same shape as the other flow handlers.
1752
+ // Hand-rolling the row removal here also meant hand-rolling the
1753
+ // "collection is now empty" message, which said "No *.user.js files
1754
+ // found." even on a container collection, whose empty state is
1755
+ // "No scripts added yet". Only container rows have this
1756
+ // button, so that was the common case, and no later repaint would
1757
+ // correct it.
1758
+ await repaintPage();
1759
+ }
1760
+ catch (err) {
1761
+ // Surface the failure rather than swallowing it — if storage rejects
1762
+ // (quota, disk error), the row stays, the button comes back, and the
1763
+ // user gets at least a console line to investigate with.
1764
+ console.info('[SourceMonkey] removeScript failed', err);
1765
+ btn.disabled = false;
1766
+ }
1767
+ }
1768
+ // ---------- Flow handlers ----------
1769
+ // First-run seeding's add path: the `DEFAULT_COLLECTIONS` locations,
1770
+ // added without asking. Everything a user types goes through
1771
+ // `onAddViaDialog` instead — the dialog is where a location gets a
1772
+ // name, an options choice, and a readability check — so this is
1773
+ // deliberately the plainer route: no dialog, no preview hand-off, no
1774
+ // scroll (several land in a row and would scroll-dance through each).
1775
+ //
1776
+ // The caller reports failures from its own `catch`, so nothing is
1777
+ // returned; the shape checks here are belt and braces over a hardcoded
1778
+ // list.
1779
+ async function seedCollection(rawPath) {
1780
+ const path = rawPath.trim();
1781
+ if (!path)
1782
+ return;
1783
+ if (!isValidLocationShape(path)) {
1784
+ showPageError(`Location ${LOCATION_SHAPES}`);
1785
+ return;
1786
+ }
1787
+ showPageError(null);
1788
+ setAddFormBusy(true);
1789
+ try {
1790
+ // Normalize once up front so any failure (rate limit, private repo,
1791
+ // network down) surfaces here with a user-visible message instead
1792
+ // of dead-ending in the page console. `addCollection` re-normalizes
1793
+ // internally, but the second call is a no-op on already-canonical
1794
+ // inputs (idempotent for non-github and for already-resolved github
1795
+ // `/tree/<ref>/...` URLs).
1796
+ let stored;
1797
+ try {
1798
+ stored = canonicalCollectionInput(await normalizeCollectionInput(path));
1799
+ }
1800
+ catch (err) {
1801
+ showPageError(err instanceof Error ? err.message : String(err));
1802
+ return;
1803
+ }
1804
+ // `addCollection` seeds the collection row inside its write lock and
1805
+ // then refreshes it, so a storage failure surfaces here as a throw
1806
+ // with the seeded row already committed. Without this the add would
1807
+ // dead-end: the form un-busies and nothing says why no section
1808
+ // appeared.
1809
+ //
1810
+ // The repaint is what makes it recoverable. Re-adding won't work —
1811
+ // the row exists, so a second attempt is a duplicate — but painting
1812
+ // the section puts its Refresh button on screen, next to the
1813
+ // could-not-be-saved message explaining what to fix first.
1814
+ let result;
1815
+ try {
1816
+ result = await addCollection(stored);
1817
+ }
1818
+ catch (err) {
1819
+ showPageError(describeError(err));
1820
+ await repaintPage();
1821
+ return;
1822
+ }
1823
+ addInput.value = '';
1824
+ // A null result is a repeat of a location already seeded (two
1825
+ // DEFAULT_COLLECTIONS entries spelling one collection): nothing to
1826
+ // add, and the section is already on screen.
1827
+ if (!result)
1828
+ return;
1829
+ // The add wrote storage; a repaint builds the new section and puts
1830
+ // everything in index order.
1831
+ await repaintPage();
1832
+ }
1833
+ finally {
1834
+ setAddFormBusy(false);
1835
+ }
1836
+ }
1837
+ // The Add field's routing, by what was typed:
1838
+ // - a single `.user.js` (URL, `file://`, or absolute path) → the
1839
+ // install page, which asks where to put it;
1840
+ // - any other URL / absolute path → the create dialog, prefilled
1841
+ // with the location, which is what the dialog classifies and names
1842
+ // the collection from;
1843
+ // - blank → the create dialog, empty;
1844
+ // - anything else → the create dialog, with the text as the name of
1845
+ // a new scripts collection.
1846
+ // The dialog does its own validation and probing, and only resolves
1847
+ // with a collection that now exists. Returns whether the input was
1848
+ // consumed — a cancelled dialog leaves the field (and a `?add=` param)
1849
+ // as it was.
1850
+ async function onAddViaDialog(rawPath, jump) {
1851
+ const text = rawPath.trim();
1852
+ showPageError(null);
1853
+ if (text && isValidLocationShape(text) && isSingleFileInput(text)) {
1854
+ // A new tab: the install question is about one file, and answering
1855
+ // it should not cost the user the collections page they were
1856
+ // adding from.
1857
+ try {
1858
+ await openExtensionTab(chrome.runtime.getURL(previewUrl(text)));
1859
+ }
1860
+ catch (err) {
1861
+ // Reported here rather than left to reject: an unhandled
1862
+ // rejection in an extension page lands in
1863
+ // chrome://extensions/?errors and reads as SourceMonkey being
1864
+ // broken. The field keeps what was typed, so the user can retry.
1865
+ showPageError(`The install page could not be opened: ${describeError(err)}`);
1866
+ return false;
1867
+ }
1868
+ // Cleared only once the tab is open — the same order the dialog
1869
+ // path below uses, so a failure never costs the user their text.
1870
+ addInput.value = '';
1871
+ return true;
1872
+ }
1873
+ // Anything shaped like a location — even a bad one, such as a bare
1874
+ // `/` or a relative path — goes in as the location, so the dialog's
1875
+ // shape check is what says what's wrong with it. Only text with no
1876
+ // scheme and no leading slash reads as a name.
1877
+ let request;
1878
+ if (!text)
1879
+ request = { mode: 'create' };
1880
+ else if (looksLikeLocation(text))
1881
+ request = { mode: 'create', prefill: { location: text } };
1882
+ else
1883
+ request = { mode: 'create', prefill: { name: text } };
1884
+ const outcome = await openCollectionDialog(request);
1885
+ if (!outcome)
1886
+ return false;
1887
+ addInput.value = '';
1888
+ await repaintPage();
1889
+ if (jump)
1890
+ jumpToCollection(outcome.collection.id);
1891
+ return true;
1892
+ }
1893
+ // The Edit button: rename, or change the local-overrides option. A
1894
+ // changed option only affects the next refresh, so the refresh is part
1895
+ // of the save — otherwise the change would appear to do nothing until
1896
+ // something else happened to touch the collection.
1897
+ async function onEditCollection(id) {
1898
+ // Re-read rather than trust the rendered row: the name may have been
1899
+ // changed from another tab since this section was painted.
1900
+ const data = await getCollectionWithScriptMeta(id);
1901
+ if (!data)
1902
+ return;
1903
+ const outcome = await openCollectionDialog({ mode: 'edit', collection: data.collection });
1904
+ if (!outcome)
1905
+ return;
1906
+ if (outcome.localFilesChanged)
1907
+ await updateCollection(id, 'manual');
1908
+ await repaintPage();
1909
+ }
1910
+ // What the Add field hands to the dialog as a location rather than a
1911
+ // name: any URL scheme, or a leading slash.
1912
+ function looksLikeLocation(value) {
1913
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(value) || value.startsWith('/');
1914
+ }
1915
+ // Show or clear the page's error line, which sits below the Add form.
1916
+ // Pass null to hide it; pass a string to surface a validation failure.
1917
+ // Shared by the Add flow, the `?refresh_file=` rejections, and what a
1918
+ // local-copy deletion could not finish — one line, so the page never
1919
+ // has two competing error slots.
1920
+ function showPageError(message) {
1921
+ if (message === null) {
1922
+ addErrorEl.textContent = '';
1923
+ addErrorEl.hidden = true;
1924
+ return;
1925
+ }
1926
+ addErrorEl.textContent = message;
1927
+ addErrorEl.hidden = false;
1928
+ }
1929
+ // Scroll the section into view and tag the URL with its anchor so a
1930
+ // reload or share-the-URL preserves the selection. Triggered after a
1931
+ // successful add and after a duplicate-add (where the section already
1932
+ // existed).
1933
+ function jumpToCollection(collectionId) {
1934
+ const section = findSection(collectionId);
1935
+ if (!section)
1936
+ return;
1937
+ history.replaceState(null, '', `#${collectionAnchorId(collectionId)}`);
1938
+ section.scrollIntoView({ behavior: 'smooth', block: 'start' });
1939
+ }
1940
+ // Build an Up or Down arrow button for a collection row. Centralizes
1941
+ // the arrow glyph, the visible/announced label, and the click wiring so
1942
+ // both buttons stay in lockstep when copy or behaviour changes.
1943
+ function makeMoveButton(direction, collectionId) {
1944
+ const arrow = direction === 'up' ? '↑' : '↓';
1945
+ const label = `Move collection ${direction}`;
1946
+ const btn = document.createElement('button');
1947
+ btn.type = 'button';
1948
+ // `.icon-btn` even though the arrow is a character rather than a
1949
+ // sprite glyph: it is one of five buttons in the header row, and the
1950
+ // row lines up because they all wear the same fixed box.
1951
+ btn.className = `icon-btn move-collection move-${direction}`;
1952
+ btn.textContent = arrow;
1953
+ btn.title = label;
1954
+ btn.setAttribute('aria-label', label);
1955
+ btn.addEventListener('click', () => void onMoveCollection(collectionId, direction));
1956
+ return btn;
1957
+ }
1958
+ // Serializes Up/Down clicks across every collection row. A user
1959
+ // clicking two arrows in quick succession (or one arrow on each of two
1960
+ // rows) would otherwise issue two interleaved read-modify-write cycles
1961
+ // on the `"collections"` index, last-write-wins — leaving the DOM out
1962
+ // of sync with storage. Routing every move through this gate keeps the
1963
+ // index write
1964
+ // + DOM swap atomic w.r.t. other moves on this page.
1965
+ let moveInFlight = Promise.resolve();
1966
+ // Swap the collection one position up or down in the index and reflect
1967
+ // the change in the DOM without rebuilding any section. The store call
1968
+ // rewrites the index row; the local DOM move swaps the two sibling
1969
+ // sections (cheaper than a re-render and preserves the per-section
1970
+ // state — checkbox spinners, scroll position, row highlights).
1971
+ async function onMoveCollection(id, direction) {
1972
+ const run = async () => {
1973
+ const section = findSection(id);
1974
+ if (!section)
1975
+ return;
1976
+ const sibling = direction === 'up'
1977
+ ? section.previousElementSibling
1978
+ : section.nextElementSibling;
1979
+ if (!(sibling instanceof HTMLElement) || !sibling.classList.contains('collection'))
1980
+ return;
1981
+ setMoveButtonsBusy(true);
1982
+ try {
1983
+ await moveCollection(id, direction);
1984
+ if (direction === 'up') {
1985
+ collectionsEl.insertBefore(section, sibling);
1986
+ }
1987
+ else {
1988
+ collectionsEl.insertBefore(sibling, section);
1989
+ }
1990
+ }
1991
+ finally {
1992
+ // Clearing the busy flag first lets refreshMoveButtons run: it
1993
+ // recomputes per-position disabled state, which is what undoes
1994
+ // setMoveButtonsBusy — each arrow gets the correct enabled value
1995
+ // for its new index.
1996
+ setMoveButtonsBusy(false);
1997
+ refreshMoveButtons();
1998
+ }
1999
+ };
2000
+ // Chain onto the prior in-flight move so the read-modify-write of
2001
+ // the index is serialized. We swallow prior rejection so one
2002
+ // failure doesn't poison subsequent moves; each run() also catches
2003
+ // its own errors via the finally above.
2004
+ moveInFlight = moveInFlight.then(run, run);
2005
+ await moveInFlight;
2006
+ }
2007
+ // Disable every move arrow on the page while a move is in flight. The
2008
+ // follow-up refreshMoveButtons() restores per-position disabled state
2009
+ // (Up off on first, Down off on last, everything else on).
2010
+ // Set while a move's index write is in flight. The write echoes back
2011
+ // into the live storage watcher, whose reconcile ends in a
2012
+ // refreshMoveButtons() — which would re-enable every arrow in the
2013
+ // middle of the move it belongs to.
2014
+ let moveButtonsBusy = false;
2015
+ function setMoveButtonsBusy(busy) {
2016
+ moveButtonsBusy = busy;
2017
+ for (const btn of collectionsEl.querySelectorAll('.move-collection')) {
2018
+ btn.disabled = busy;
2019
+ }
2020
+ }
2021
+ // Walk every rendered collection section and set each Up/Down button's
2022
+ // disabled state from its current position: Up off on the first
2023
+ // section, Down off on the last. Called after the initial render,
2024
+ // after any move, and after add/remove flows change the section count.
2025
+ // Filters on `section.collection` so the empty-list placeholder
2026
+ // (`p.empty-placeholder`) that shares this container is ignored.
2027
+ function refreshMoveButtons() {
2028
+ if (moveButtonsBusy)
2029
+ return;
2030
+ const sections = collectionsEl.querySelectorAll('section.collection');
2031
+ const last = sections.length - 1;
2032
+ sections.forEach((section, i) => {
2033
+ const up = section.querySelector('.move-up');
2034
+ const down = section.querySelector('.move-down');
2035
+ if (up)
2036
+ up.disabled = i === 0;
2037
+ if (down)
2038
+ down.disabled = i === last;
2039
+ });
2040
+ }
2041
+ async function onRemoveCollection(id, section, btn) {
2042
+ // Guard non-empty collections behind a confirm: removing a collection
2043
+ // drops every script it owns, and the action is irreversible from the
2044
+ // UI. For an empty collection there's nothing to lose, so skip the
2045
+ // prompt.
2046
+ //
2047
+ // The count read is a snapshot — a background refresh adding or
2048
+ // removing a script while the dialog is open could leave the number
2049
+ // slightly stale. The window is short and the error bounded; not
2050
+ // worth a re-read.
2051
+ const data = await getCollectionWithScriptMeta(id);
2052
+ if (!data) {
2053
+ // Collection row has been removed elsewhere (e.g. another tab).
2054
+ // Nothing to remove from the store; drop the orphan section.
2055
+ section.remove();
2056
+ return;
2057
+ }
2058
+ const scriptCount = data.scripts.length;
2059
+ if (scriptCount > 0) {
2060
+ const scriptWord = scriptCount === 1 ? 'script' : 'scripts';
2061
+ if (!confirm(`Remove collection with ${scriptCount} ${scriptWord}?`))
2062
+ return;
2063
+ }
2064
+ section.classList.add('removing');
2065
+ btn.disabled = true;
2066
+ try {
2067
+ await removeCollection(id);
2068
+ }
2069
+ catch (err) {
2070
+ section.classList.remove('removing');
2071
+ btn.disabled = false;
2072
+ // On the page, where the user is looking, rather than the console.
2073
+ showPageError(`Could not remove the collection: ${describeError(err)}`);
2074
+ return;
2075
+ }
2076
+ // The repaint drops the section and, if that was the last collection,
2077
+ // puts the empty placeholder back.
2078
+ await repaintPage();
2079
+ }
2080
+ // Snapshot / refresh / repaint / highlight — the shape every refresh
2081
+ // flow on this page shares. `run` does the actual refreshing;
2082
+ // `collectionIds` names the sections to diff, which for a
2083
+ // script-level refresh is the section that holds the script.
2084
+ //
2085
+ // Refresh may have added/removed scripts, flipped statuses, etc. The
2086
+ // repaint brings the sections' rows in line with what was just
2087
+ // written; then the rows that actually changed get their highlight.
2088
+ // Rows are updated in place, so the highlight goes onto the same `<tr>`
2089
+ // the user was already looking at.
2090
+ //
2091
+ // Never throws: a refresh that couldn't be saved already recorded its
2092
+ // reason in session storage, so the repaint is what puts it on screen.
2093
+ // Callers are click handlers whose `void` would drop a rejection
2094
+ // silently, leaving the user watching a button finish with no
2095
+ // explanation.
2096
+ async function refreshAndHighlight(collectionIds, label, run) {
2097
+ try {
2098
+ // Snapshot the prior scripts so we can highlight rows that
2099
+ // actually changed after the refresh lands.
2100
+ const priors = new Map();
2101
+ await Promise.all(collectionIds.map(async (id) => {
2102
+ const before = await getCollectionWithScriptMeta(id);
2103
+ priors.set(id, before?.scripts ?? []);
2104
+ }));
2105
+ await run();
2106
+ await repaintPage();
2107
+ for (const id of collectionIds) {
2108
+ const section = findSection(id);
2109
+ if (!section)
2110
+ continue;
2111
+ const after = await getCollectionWithScriptMeta(id);
2112
+ applyChangeHighlights(section, priors.get(id) ?? [], after?.scripts ?? [],
2113
+ // A collection that has itself gone leaves no row to read the
2114
+ // local-overrides toggle from; ghosts there resolve their icons
2115
+ // without local stand-ins.
2116
+ after ? localFileScope(after.collection) : { enabled: false });
2117
+ }
2118
+ }
2119
+ catch (err) {
2120
+ console.info(`[SourceMonkey] ${label} failed`, err);
2121
+ await repaintPage();
2122
+ }
2123
+ }
2124
+ async function onRefreshCollection(id, btn) {
2125
+ setGlyphButtonBusy(btn, true, REFRESH_COLLECTION_LABELS);
2126
+ try {
2127
+ // Goes through script-updater so a background opportunistic check
2128
+ // already in flight for this collection coalesces with our click
2129
+ // instead of doing redundant work.
2130
+ await refreshAndHighlight([id], 'collection refresh', () => updateCollection(id, 'manual'));
2131
+ }
2132
+ finally {
2133
+ setGlyphButtonBusy(btn, false, REFRESH_COLLECTION_LABELS);
2134
+ }
2135
+ }
2136
+ // Refresh every registered directory in parallel, then re-render each
2137
+ // section in place and flash any rows that changed. Triggered by the
2138
+ // header "Refresh All" button and by ?refresh in the URL.
2139
+ async function onRefreshAll() {
2140
+ setRefreshAllBusy(true);
2141
+ try {
2142
+ // Inside the try, and inside a flow that swallows: the click
2143
+ // handler's `void` would turn a throw from this read into an
2144
+ // unhandled rejection, which Chrome files under the extension's
2145
+ // errors even though nothing here is the user's to fix.
2146
+ const collections = await getCollections();
2147
+ // `refreshAllCollections` records each collection's failure and
2148
+ // keeps going, so anything `refreshAndHighlight` catches broke
2149
+ // outside that loop. It repaints anyway: whatever DID save should
2150
+ // show, and any recorded per-collection reason goes up with it.
2151
+ await refreshAndHighlight(collections.map((c) => c.id), 'refresh all', () => updateAllCollections('manual'));
2152
+ }
2153
+ catch (err) {
2154
+ console.info('[SourceMonkey] refresh all failed', err);
2155
+ }
2156
+ finally {
2157
+ setRefreshAllBusy(false);
2158
+ }
2159
+ }
2160
+ // Refresh a chosen subset — the `?refresh_local` / `?refresh_file=`
2161
+ // triggers. Collections refresh whole (a re-scan is what notices added,
2162
+ // renamed, and deleted files); individually-selected scripts refresh on
2163
+ // their own, which is the only option for a local script in a scripts
2164
+ // collection since it has no directory to scan.
2165
+ //
2166
+ // Bounded like the store's own fan-out so a wide target (a tree holding
2167
+ // many collections) doesn't open every collection's fetch set at once.
2168
+ //
2169
+ // Unlike Refresh All this is a plain loop over `updateCollection`, so
2170
+ // the targets don't share one file-fetch cache and each runs its own
2171
+ // update-probe pass. Fine for a subset (usually one collection); don't
2172
+ // assume parity with `refreshAllCollections` here.
2173
+ async function onRefreshSubset(targets) {
2174
+ setRefreshAllBusy(true);
2175
+ try {
2176
+ // Highlight the section behind each refreshed script too, not just
2177
+ // the wholly-refreshed collections.
2178
+ const sections = new Set(targets.collectionIds);
2179
+ for (const script of targets.scripts)
2180
+ sections.add(script.collectionId);
2181
+ await refreshAndHighlight([...sections], 'targeted refresh', async () => {
2182
+ // Per-target catch, the way `refreshAllCollections` wraps each
2183
+ // collection: `mapWithConcurrency` rejects on the FIRST failure,
2184
+ // which would otherwise abandon the sibling collections and skip
2185
+ // the script pass below entirely. Each failure has already
2186
+ // recorded its own reason where the repaint will show it.
2187
+ await mapWithConcurrency(targets.collectionIds, FETCH_COLLECTIONS_CONCURRENCY, (id) => updateCollection(id, 'manual').catch((err) => {
2188
+ console.info(`[SourceMonkey] collection refresh failed: ${describeError(err)}`);
2189
+ return null;
2190
+ }));
2191
+ await mapWithConcurrency(targets.scripts, FETCH_SCRIPTS_PER_COLLECTION_CONCURRENCY, (script) => updateScript(script.id, 'manual', 'force').catch((err) => {
2192
+ console.info(`[SourceMonkey] script refresh failed: ${describeError(err)}`);
2193
+ return null;
2194
+ }));
2195
+ });
2196
+ }
2197
+ finally {
2198
+ setRefreshAllBusy(false);
2199
+ }
2200
+ }
2201
+ // Decide per script whether it changed in a refresh and tag the
2202
+ // corresponding row. Two independent signals ride on the row, because
2203
+ // they answer two different questions:
2204
+ //
2205
+ // - WHAT happened to it — `row-added` / `row-updated`, which paint
2206
+ // the wash and put a "New" / "Updated" chip beside the status.
2207
+ // - HOW it came out — `row-failed`, which overrides the wash with
2208
+ // red. A script that arrived broken is still new, so it keeps its
2209
+ // chip and only loses the green.
2210
+ //
2211
+ // Scripts the refresh DROPPED get a tombstone instead: an inert ghost
2212
+ // row in the place the script used to hold (see `addTombstones`).
2213
+ //
2214
+ // The tags persist on the row until the next refresh, which clears the
2215
+ // prior ones before applying its own — rows are reused across a
2216
+ // repaint, so nothing else drops them.
2217
+ //
2218
+ // Classification:
2219
+ // - newly-added → row-added (+ row-failed when it isn't ok)
2220
+ // - dropped → tombstone row
2221
+ // - re-read → row-updated (+ row-failed when it isn't ok)
2222
+ // - status changed but no re-read (rare) → row-updated, likewise
2223
+ // - refresh just failed (refreshError newly set / changed, while the
2224
+ // cached body keeps running) → row-failed, no chip: nothing was
2225
+ // re-read, so there is no update to announce
2226
+ // - refresh recovered (refreshError cleared) → row-updated, even when
2227
+ // the body is unchanged (304 / size+mtime skip preserve loadedAt)
2228
+ // - otherwise → no tag (unchanged path: cheap-skip
2229
+ // optimization, OR genuine no-op)
2230
+ function applyChangeHighlights(section, prior, next,
2231
+ // The collection's local-file scope, so a ghost resolves its favicon
2232
+ // exactly as the live row did — including an `@icon` that points at a
2233
+ // file on disk.
2234
+ localFiles) {
2235
+ // Clear the last refresh's tags first. Rows survive a refresh now
2236
+ // (they're updated in place, not rebuilt), so without this a row that
2237
+ // changed once would stay highlighted through every later refresh.
2238
+ for (const row of section.querySelectorAll('tr[data-script-id]')) {
2239
+ row.classList.remove('row-added', 'row-updated', 'row-failed');
2240
+ }
2241
+ removeTombstones(section);
2242
+ const priorById = new Map(prior.map((s) => [s.id, s]));
2243
+ for (const n of next) {
2244
+ const change = classifyChange(priorById.get(n.id), n);
2245
+ if (!change)
2246
+ continue;
2247
+ const row = section.querySelector(`tr[data-script-id="${cssEscape(n.id)}"]`);
2248
+ if (!row)
2249
+ continue;
2250
+ if (change.kind)
2251
+ row.classList.add(`row-${change.kind}`);
2252
+ if (change.failed)
2253
+ row.classList.add('row-failed');
2254
+ }
2255
+ addTombstones(section, prior, new Set(next.map((s) => s.id)), localFiles);
2256
+ }
2257
+ function classifyChange(prior, next) {
2258
+ const failed = next.status !== 'ok';
2259
+ if (!prior)
2260
+ return { kind: 'added', failed };
2261
+ // A fresh refresh failure (the cached body keeps running, so status
2262
+ // stays `ok` and loadedAt doesn't move) still deserves a red flash so
2263
+ // the user notices the collection went stale.
2264
+ if (next.refreshError && next.refreshError !== prior.refreshError) {
2265
+ return { kind: null, failed: true };
2266
+ }
2267
+ // Recovery: a row that WAS refresh-failed and now isn't reads as an
2268
+ // update even when the body is unchanged (HTTP 304 / size+mtime skip,
2269
+ // which preserve loadedAt) — otherwise the row would silently drop its
2270
+ // red highlight with no "it came back" confirmation.
2271
+ if (prior.refreshError && !next.refreshError)
2272
+ return { kind: 'updated', failed };
2273
+ const reread = next.loadedAt !== prior.loadedAt;
2274
+ const statusChanged = next.status !== prior.status;
2275
+ if (!reread && !statusChanged)
2276
+ return null;
2277
+ return { kind: 'updated', failed };
2278
+ }
2279
+ // ---------- Tombstones (dropped scripts) ----------
2280
+ //
2281
+ // A script the scan no longer reports is DROPPED: its row, its storage
2282
+ // key, its Log and its `gmval:` values all go, and none of it comes
2283
+ // back if the file returns (see docs/storage.md, "Vanished entries").
2284
+ // Nothing else on the page would say so — the row would simply not be
2285
+ // there the next time the user looked.
2286
+ //
2287
+ // So a refresh that drops a script leaves a ghost of it in the row's
2288
+ // old place: struck through, greyed, and inert. It lives in the DOM
2289
+ // only, alongside the other refresh tags: the next refresh clears it,
2290
+ // and a reload never rebuilds it.
2291
+ //
2292
+ // The ghost carries `data-tombstone` and NOT `data-script-id`, which is
2293
+ // what keeps every walker on this page — status repaints, log-suffix
2294
+ // updates, bulk-toggle counting, row reconciliation — away from a row
2295
+ // with no stored script behind it.
2296
+ // `data-tombstone-after` names the surviving row a ghost sits below, so
2297
+ // it can be put back in place after a repaint re-seats the live rows.
2298
+ // Empty means "top of the table".
2299
+ //
2300
+ // The comparison is the refresh's own before/after snapshot, so a script
2301
+ // that left for another reason while the refresh ran (a trash can click
2302
+ // here, a removal in another tab) is ghosted as though the refresh
2303
+ // dropped it. A narrow window, and the row did leave — only the reason
2304
+ // on the hover text would be wrong.
2305
+ function addTombstones(section, prior, survivingIds, localFiles) {
2306
+ // No table to hang them off: the collection lost its last script and
2307
+ // the block was swapped for the "no scripts" message.
2308
+ const tbody = section.querySelector(':scope > .script-block tbody');
2309
+ if (!tbody)
2310
+ return;
2311
+ const inContainer = !!section.querySelector('thead th[aria-label="Source"]');
2312
+ const withActions = !!section.querySelector('thead th[aria-label="Actions"]');
2313
+ let anchorId = '';
2314
+ // Walked in the order the rows were shown, so each ghost is anchored
2315
+ // below the row it sat under.
2316
+ for (const script of sortedByShownName(prior, inContainer)) {
2317
+ if (survivingIds.has(script.id)) {
2318
+ anchorId = script.id;
2319
+ continue;
2320
+ }
2321
+ tbody.appendChild(renderTombstoneRow(script, anchorId, inContainer, withActions, localFiles));
2322
+ }
2323
+ placeTombstones(tbody);
2324
+ // A ghost keeps its icons, and the wide row that just left may have
2325
+ // been the one the column was fitted to.
2326
+ fitIconColumns(tbody.parentElement, []);
2327
+ }
2328
+ function renderTombstoneRow(script, anchorId, inContainer, withActions, localFiles) {
2329
+ const tr = document.createElement('tr');
2330
+ tr.className = 'row-removed';
2331
+ tr.dataset.tombstone = script.id;
2332
+ tr.dataset.tombstoneAfter = anchorId;
2333
+ // Announced as not-actionable rather than made `inert`: an inert
2334
+ // subtree isn't hit-tested, which silently takes the row's own
2335
+ // explanation (the Status cell's hover text) away with it, and hides
2336
+ // the row from screen readers — on the one row whose whole purpose is
2337
+ // to say something irreversible happened. Nothing here is focusable
2338
+ // anyway: the checkbox is disabled and the favicon is de-linked below.
2339
+ tr.setAttribute('aria-disabled', 'true');
2340
+ const toggleCell = document.createElement('td');
2341
+ toggleCell.className = 'toggle-cell';
2342
+ // The same box a live row's toggle sits in, under a different class:
2343
+ // the bulk toggle's census counts `.toggle` checkboxes and this one
2344
+ // must not join it, but a bare baseline-aligned checkbox would sit a
2345
+ // pixel or two off and make the ghost row a different height from the
2346
+ // rows around it.
2347
+ const toggleBox = document.createElement('span');
2348
+ toggleBox.className = 'toggle-ghost';
2349
+ const cb = document.createElement('input');
2350
+ cb.type = 'checkbox';
2351
+ cb.checked = isScriptEnabled(script);
2352
+ cb.disabled = true;
2353
+ toggleBox.appendChild(cb);
2354
+ toggleCell.appendChild(toggleBox);
2355
+ tr.appendChild(toggleCell);
2356
+ const nameCell = document.createElement('td');
2357
+ nameCell.className = 'name-cell';
2358
+ // The favicon slot is painted the same way a live row's is, from the
2359
+ // snapshot we still hold: the icon is what the eye finds the row by,
2360
+ // and an empty slot would leave the name hanging in the column's
2361
+ // negative text-indent, out of line with every row around it. The
2362
+ // fetch it starts is the one the live row just made, so it comes back
2363
+ // from the icon cache.
2364
+ const iconSlot = document.createElement('span');
2365
+ iconSlot.className = 'site-icon';
2366
+ nameCell.appendChild(iconSlot);
2367
+ const iconsDone = renderSiteIconInto(iconSlot, script, localSearchRoots(script.fileUrl, localFiles));
2368
+ // Drop the href in place rather than replacing the anchor: the icon
2369
+ // fetch resolves into the very element `renderSiteIconInto` built, so
2370
+ // swapping it out would leave the favicon painting into a detached
2371
+ // node. Without the href it is no longer a link or a tab stop, which
2372
+ // is what a row with nothing left to open should be.
2373
+ //
2374
+ // Again once the icons have loaded: until then the slot may hold only
2375
+ // the first site's icon, and the others arrive as links.
2376
+ const unlinkIcons = () => {
2377
+ for (const iconLink of iconSlot.querySelectorAll('a.site-icon-inner')) {
2378
+ iconLink.removeAttribute('href');
2379
+ iconLink.removeAttribute('target');
2380
+ iconLink.removeAttribute('rel');
2381
+ }
2382
+ };
2383
+ unlinkIcons();
2384
+ void iconsDone.then(unlinkIcons);
2385
+ // Text, not a link: the viewer has nothing to open for a script whose
2386
+ // record is gone.
2387
+ const name = document.createElement('span');
2388
+ name.className = 'tombstone-name';
2389
+ appendWithSlashBreaks(name, nameCellText(script, inContainer));
2390
+ nameCell.appendChild(name);
2391
+ tr.appendChild(nameCell);
2392
+ // For column parity with the live table. A container collection can't
2393
+ // actually reach here — its scan is built from the scripts it holds,
2394
+ // so nothing in one ever reads as vanished — but a table whose rows
2395
+ // disagree about column count stops lining up.
2396
+ if (inContainer) {
2397
+ const schemeCell = document.createElement('td');
2398
+ schemeCell.className = 'scheme-cell';
2399
+ schemeCell.appendChild(makeSchemeChip(script.relativePath));
2400
+ tr.appendChild(schemeCell);
2401
+ }
2402
+ const sizeCell = document.createElement('td');
2403
+ sizeCell.className = 'size-cell';
2404
+ sizeCell.textContent = formatScriptSize(script);
2405
+ tr.appendChild(sizeCell);
2406
+ const statusCell = document.createElement('td');
2407
+ statusCell.className = 'status-cell';
2408
+ statusCell.title = TOMBSTONE_TITLE;
2409
+ // A real element, unlike the New / Updated chips, which are `::after`
2410
+ // on a cell that gets rebuilt from stored state. Nothing rebuilds this
2411
+ // cell (no `data-script-id`, so no repaint reaches it), and this chip
2412
+ // is the row's whole status — text a reader can select, copy, and hear
2413
+ // read out, not a decoration layered over a status word.
2414
+ const chip = document.createElement('span');
2415
+ chip.className = 'change-chip chip-removed';
2416
+ chip.textContent = 'Removed';
2417
+ statusCell.appendChild(chip);
2418
+ tr.appendChild(statusCell);
2419
+ if (withActions)
2420
+ tr.appendChild(document.createElement('td'));
2421
+ return tr;
2422
+ }
2423
+ const TOMBSTONE_TITLE = 'This script is no longer in the collection, so the last refresh removed it '
2424
+ + 'along with its log and its stored values. This row is only a reminder: it '
2425
+ + 'goes away on the next refresh, or when you reload the page.';
2426
+ // The rows a stored script stands behind, in DOM order. Every walk over
2427
+ // the table goes through one of these two: `data-script-id` is what
2428
+ // separates a real row from a tombstone, and a walk that forgets it
2429
+ // ends up toggling, counting or reordering a row with no script.
2430
+ function liveScriptRows(tbody) {
2431
+ return [...tbody.querySelectorAll(':scope > tr[data-script-id]')];
2432
+ }
2433
+ function sectionScriptRows(section) {
2434
+ return [...section.querySelectorAll('tbody tr[data-script-id]')];
2435
+ }
2436
+ function removeTombstones(root) {
2437
+ for (const row of root.querySelectorAll('tr[data-tombstone]'))
2438
+ row.remove();
2439
+ }
2440
+ // Put each ghost back below the row it was recorded against.
2441
+ //
2442
+ // The live-row check is defensive: a dropped script's UUID goes with it,
2443
+ // so a file that comes back comes back as a different row and can't
2444
+ // collide. It costs one map lookup to be sure the table never shows the
2445
+ // same id twice.
2446
+ function placeTombstones(tbody) {
2447
+ const ghosts = [...tbody.querySelectorAll(':scope > tr[data-tombstone]')];
2448
+ if (ghosts.length === 0)
2449
+ return;
2450
+ const live = new Map(liveScriptRows(tbody).map((row) => [row.dataset.scriptId ?? '', row]));
2451
+ // Where the next ghost for a given anchor goes. Seeded with the
2452
+ // anchor row itself and advanced past each ghost placed against it,
2453
+ // so a run of scripts deleted together keeps the order they had.
2454
+ const cursors = new Map();
2455
+ for (const ghost of ghosts) {
2456
+ if (live.has(ghost.dataset.tombstone ?? '')) {
2457
+ ghost.remove();
2458
+ continue;
2459
+ }
2460
+ const anchorId = ghost.dataset.tombstoneAfter ?? '';
2461
+ const cursor = cursors.get(anchorId) ?? live.get(anchorId);
2462
+ // No cursor means either an empty anchor (the ghost led the table)
2463
+ // or an anchor that has itself left the table since — a script moved
2464
+ // to another collection, or removed by a click while the refresh ran.
2465
+ // Both go to the top, which is what an empty anchor asks for anyway.
2466
+ if (cursor)
2467
+ cursor.after(ghost);
2468
+ else
2469
+ tbody.insertBefore(ghost, tbody.firstChild);
2470
+ cursors.set(anchorId, ghost);
2471
+ }
2472
+ }
2473
+ // Turning a collection on turns off the other running copies of the
2474
+ // scripts it starts; turning it off brings back the copies they
2475
+ // displaced. Ctrl/Cmd-click (`solo`) flips only the collection. See
2476
+ // docs/script-enablement.md.
2477
+ async function onToggleCollection(id, enabled, label, solo) {
2478
+ setToggleBusy(label, true);
2479
+ try {
2480
+ const written = await setCollectionEnabledExclusive(id, enabled, solo);
2481
+ const section = findSection(id);
2482
+ if (section)
2483
+ applyCollectionEnabledToDom(section, enabled);
2484
+ applyEnableResult(written.scripts, [COLLECTION_PREFIX + id]);
2485
+ }
2486
+ finally {
2487
+ setToggleBusy(label, false);
2488
+ }
2489
+ }
2490
+ // An enable write that failed (in the worker, or reaching it): the
2491
+ // boxes go back to what is stored. Logged, not thrown: a rejection the
2492
+ // click handler drops would land in the extension's Errors panel.
2493
+ function onEnableFailure(err) {
2494
+ console.info('[SourceMonkey] could not change what is enabled:', describeError(err));
2495
+ void repaintPage();
2496
+ }
2497
+ // Turning a script on turns its other running copies off, unless
2498
+ // Ctrl/Cmd-clicked (`solo`). Their rows change with this one's.
2499
+ async function onToggleScript(id, enabled, label, solo) {
2500
+ setToggleBusy(label, true);
2501
+ try {
2502
+ const { changed } = await setScriptEnabledExclusive(id, enabled, solo);
2503
+ applyEnableResult(changed, [id]);
2504
+ const row = label.closest('tr');
2505
+ const section = label.closest('.collection');
2506
+ if (row && section) {
2507
+ const collectionOn = !section.classList.contains('disabled');
2508
+ row.classList.toggle('disabled', !collectionOn || !enabled);
2509
+ refreshBulkToggle(section);
2510
+ }
2511
+ }
2512
+ finally {
2513
+ setToggleBusy(label, false);
2514
+ }
2515
+ }
2516
+ // "Enable all" turns on one copy of each script in the collection (see
2517
+ // `setAllScriptsEnabled`), and turns its other running copies off
2518
+ // elsewhere unless Ctrl/Cmd-clicked (`solo`). "Disable all" turns every
2519
+ // script off.
2520
+ async function onBulkToggleScripts(collectionId, btn, solo) {
2521
+ const section = findSection(collectionId);
2522
+ if (!section)
2523
+ return;
2524
+ const checkboxes = Array.from(section.querySelectorAll('tbody .toggle input[type="checkbox"]'));
2525
+ if (checkboxes.length === 0)
2526
+ return;
2527
+ const next = !allScriptsOn(section);
2528
+ const labels = checkboxes.map((cb) => cb.closest('.toggle'));
2529
+ setBulkBusy(btn, true);
2530
+ for (const lab of labels)
2531
+ setToggleBusy(lab, true);
2532
+ try {
2533
+ // One bulk write → one storage.onChanged event → one
2534
+ // syncRegistrations in the background SW. The old code wrote each
2535
+ // script's row individually, so a 15-script flip kicked off 15
2536
+ // full re-syncs of chrome.userScripts (each O(N)).
2537
+ const changed = await setAllScriptsEnabled(collectionId, next, solo);
2538
+ // Copies it turned off in other collections; this section's rows are
2539
+ // drawn from storage below.
2540
+ applyEnableResult(changed.filter((s) => s.collectionId !== collectionId), sectionScriptRows(section).map((row) => row.dataset.scriptId ?? ''));
2541
+ // Reflect the stored state in every row: "Enable all" may leave a
2542
+ // second copy of a script off.
2543
+ const collectionOn = !section.classList.contains('disabled');
2544
+ const data = await getCollectionWithScriptMeta(collectionId);
2545
+ const on = new Map((data?.scripts ?? []).map((s) => [s.id, isScriptEnabled(s)]));
2546
+ for (const row of sectionScriptRows(section)) {
2547
+ const enabled = on.get(row.dataset.scriptId ?? '') ?? false;
2548
+ const cb = row.querySelector('.toggle input[type="checkbox"]');
2549
+ if (cb)
2550
+ cb.checked = enabled;
2551
+ row.classList.toggle('disabled', !collectionOn || !enabled);
2552
+ }
2553
+ }
2554
+ finally {
2555
+ for (const lab of labels)
2556
+ setToggleBusy(lab, false);
2557
+ setBulkBusy(btn, false);
2558
+ refreshBulkToggle(section);
2559
+ }
2560
+ }
2561
+ // Whether every script in the collection has a copy on: what flips the
2562
+ // bulk toggle to "Disable all". Copies sharing `@namespace` + `@name`
2563
+ // count as one script, since "Enable all" turns on only one of them.
2564
+ function allOnByIdentity(rows) {
2565
+ if (rows.length === 0)
2566
+ return false;
2567
+ const anyOn = new Map();
2568
+ for (const r of rows)
2569
+ anyOn.set(r.identity, (anyOn.get(r.identity) ?? false) || r.on);
2570
+ return [...anyOn.values()].every(Boolean);
2571
+ }
2572
+ function allScriptsOn(section) {
2573
+ return allOnByIdentity(sectionScriptRows(section).flatMap((row) => {
2574
+ const cb = row.querySelector('.toggle input[type="checkbox"]');
2575
+ return cb ? [{ identity: row.dataset.identity ?? '', on: cb.checked }] : [];
2576
+ }));
2577
+ }
2578
+ // The identity a row is grouped by for `allOnByIdentity`: a script
2579
+ // without `@name` stands alone.
2580
+ function rowIdentity(script) {
2581
+ return identityKey(script.header ?? null) ?? `id:${script.id}`;
2582
+ }
2583
+ // ---------- In-place DOM mutators ----------
2584
+ function findSection(collectionId) {
2585
+ return collectionsEl.querySelector(`.collection[data-collection-id="${cssEscape(collectionId)}"]`);
2586
+ }
2587
+ // Apply a collection enable/disable to its section without rebuilding it:
2588
+ // flip the section's `.disabled` class and its own header toggle,
2589
+ // retitle the per-script toggle labels, enable/disable each per-script
2590
+ // checkbox, and update the bulk-toggle's disabled state. Per-script
2591
+ // `enabled` values are preserved in storage, so the row classes
2592
+ // recompute from each checkbox's current `checked`.
2593
+ //
2594
+ // A checkbox whose label carries a `.busy-spinner` belongs to a click
2595
+ // this page is still awaiting, and is left alone: it shows the value the
2596
+ // user just picked, and the handler's `finally` settles it. The label's
2597
+ // styling and tooltip are updated anyway — they say what the COLLECTION is,
2598
+ // not what the user clicked, and `setToggleBusy`'s restore reads
2599
+ // `toggle-disabled` back to decide the checkbox's final disabled state.
2600
+ // Skipping the label too would leave a checkbox enabled under a collection
2601
+ // that was disabled while its spinner was up, with nothing left to
2602
+ // repaint it.
2603
+ function applyCollectionEnabledToDom(section, enabled) {
2604
+ section.classList.toggle('disabled', !enabled);
2605
+ // The collection's own toggle lives in the section header, outside
2606
+ // tbody. `onToggleCollection` arrives with it already flipped by the
2607
+ // click, but the watcher does not — without this a collection
2608
+ // disabled from another tab greys the whole section while its toggle
2609
+ // still reads "on".
2610
+ const collectionLabel = section.querySelector('.collection-toggle');
2611
+ const collectionCb = collectionLabel?.querySelector('input[type="checkbox"]');
2612
+ if (collectionCb && !collectionLabel?.querySelector('.busy-spinner')) {
2613
+ collectionCb.checked = enabled;
2614
+ }
2615
+ for (const row of sectionScriptRows(section)) {
2616
+ const cb = row.querySelector('.toggle input[type="checkbox"]');
2617
+ const lab = row.querySelector('.toggle');
2618
+ if (!cb || !lab)
2619
+ continue;
2620
+ lab.classList.toggle('toggle-disabled', !enabled);
2621
+ lab.title = enabled ? ENABLE_TITLE : COLLECTION_OFF_TITLE;
2622
+ if (!lab.querySelector('.busy-spinner'))
2623
+ cb.disabled = !enabled;
2624
+ row.classList.toggle('disabled', !enabled || !cb.checked);
2625
+ }
2626
+ refreshBulkToggle(section);
2627
+ }
2628
+ // Recompute the bulk-toggle's symbol/title/disabled state from the
2629
+ // current per-row checkboxes plus the section's enabled state. Called
2630
+ // after any toggle so the +/− stays in sync without a re-render.
2631
+ function refreshBulkToggle(section) {
2632
+ const btn = section.querySelector('.bulk-toggle');
2633
+ if (!btn)
2634
+ return;
2635
+ const collectionOn = !section.classList.contains('disabled');
2636
+ const allOn = allScriptsOn(section);
2637
+ btn.title = bulkTitle(collectionOn, allOn);
2638
+ // The glyph can be written even while busy — `.bulk-toggle.busy`
2639
+ // paints it transparent under the spinner, and it needs to be correct
2640
+ // for the moment the spinner clears.
2641
+ btn.textContent = allOn ? '−' : '+';
2642
+ // The disabled state must NOT be written while busy: setBulkBusy holds
2643
+ // the button disabled while its spinner is up. A bulk toggle writes
2644
+ // every row at once, and the echo of that write reaches this function
2645
+ // once per row — each call would otherwise re-enable the button
2646
+ // mid-operation.
2647
+ if (!btn.classList.contains('busy'))
2648
+ btn.disabled = !collectionOn;
2649
+ }
2650
+ function bulkTitle(collectionOn, allOn) {
2651
+ if (!collectionOn)
2652
+ return COLLECTION_OFF_TITLE;
2653
+ return allOn
2654
+ ? 'Disable all scripts in this collection'
2655
+ : 'Enable all scripts in this collection (one copy of each)';
2656
+ }
2657
+ function removeEmptyPlaceholder() {
2658
+ collectionsEl.querySelector('.empty-placeholder')?.remove();
2659
+ }
2660
+ // ---------- Busy indicators ----------
2661
+ // Append/remove a small spinner inside a toggle label and disable the
2662
+ // underlying checkbox while async work is in flight. The checkbox keeps
2663
+ // its just-clicked `checked` value so the user sees the intended state
2664
+ // immediately.
2665
+ //
2666
+ // The CSS overlays the spinner on the checkbox rather than letting it
2667
+ // sit in flow: the script table's toggle cell sizes to its content, so
2668
+ // an in-flow spinner widened that column and shifted every row in the
2669
+ // collection sideways until the write landed.
2670
+ function setToggleBusy(label, busy) {
2671
+ const cb = label.querySelector('input[type="checkbox"]');
2672
+ if (cb)
2673
+ cb.disabled = busy || label.classList.contains('toggle-disabled');
2674
+ let spinner = label.querySelector('.busy-spinner');
2675
+ if (busy) {
2676
+ if (!spinner) {
2677
+ spinner = document.createElement('span');
2678
+ spinner.className = 'busy-spinner';
2679
+ label.appendChild(spinner);
2680
+ }
2681
+ }
2682
+ else if (spinner) {
2683
+ spinner.remove();
2684
+ }
2685
+ }
2686
+ // Show a spinner over the bulk-toggle's +/− glyph while async work is
2687
+ // in flight. `.bulk-toggle.busy` paints the glyph transparent and draws
2688
+ // the ring on top, so the glyph stays in the button: emptying it moved
2689
+ // the baseline of this inline-flex button, which resized the header row
2690
+ // by a pixel and jiggled every collection below it for as long as the
2691
+ // write ran.
2692
+ //
2693
+ // Clearing busy leaves the button ENABLED: callers must follow with
2694
+ // `refreshBulkToggle`, synchronously, to settle it against the
2695
+ // collection's own on/off state.
2696
+ function setBulkBusy(btn, busy) {
2697
+ btn.classList.toggle('busy', busy);
2698
+ btn.disabled = busy;
2699
+ }
2700
+ function setRefreshAllBusy(busy) {
2701
+ setGlyphButtonBusy(refreshAllBtn, busy, {
2702
+ idle: 'Refresh all collections',
2703
+ busy: 'Refreshing all collections…',
2704
+ });
2705
+ }
2706
+ function setAddFormBusy(busy) {
2707
+ addInput.disabled = busy;
2708
+ addButton.disabled = busy;
2709
+ let spinner = addForm.querySelector('.busy-spinner');
2710
+ if (busy) {
2711
+ if (!spinner) {
2712
+ spinner = document.createElement('span');
2713
+ spinner.className = 'busy-spinner';
2714
+ addForm.appendChild(spinner);
2715
+ }
2716
+ }
2717
+ else if (spinner) {
2718
+ spinner.remove();
2719
+ }
2720
+ }
2721
+ // ---------- Small UI helpers ----------
2722
+ // A checkbox-sized button with a "−" (when everything's on, so the
2723
+ // click disables) or "+" (otherwise — click enables). Distinct
2724
+ // element type from the per-row checkbox toggle so it can carry its
2725
+ // own affordance without confusing screen-readers about its state.
2726
+ function makeBulkToggle(allOn, disabled,
2727
+ // `solo`: Ctrl/Cmd was held.
2728
+ onClick, title) {
2729
+ const btn = document.createElement('button');
2730
+ btn.type = 'button';
2731
+ btn.className = 'bulk-toggle';
2732
+ btn.disabled = disabled;
2733
+ btn.title = title;
2734
+ btn.textContent = allOn ? '−' : '+';
2735
+ btn.addEventListener('click', (ev) => { if (!btn.disabled)
2736
+ onClick(ev.ctrlKey || ev.metaKey); });
2737
+ return btn;
2738
+ }
2739
+ // A label-wrapped checkbox so the user has a generous click target.
2740
+ // `disabled: true` greys it out without losing the underlying value.
2741
+ // The change handler receives the new value AND the label, so flow
2742
+ // handlers can attach a spinner to it while async work is in flight.
2743
+ function makeToggle(checked, disabled,
2744
+ // `solo`: the click held Ctrl/Cmd (`watchSoloModifier`).
2745
+ onChange, title) {
2746
+ const label = document.createElement('label');
2747
+ label.className = 'toggle';
2748
+ if (disabled)
2749
+ label.classList.add('toggle-disabled');
2750
+ label.title = title;
2751
+ const cb = document.createElement('input');
2752
+ cb.type = 'checkbox';
2753
+ cb.checked = checked;
2754
+ cb.disabled = disabled;
2755
+ const takeSolo = watchSoloModifier(cb);
2756
+ cb.addEventListener('change', () => onChange(cb.checked, label, takeSolo()));
2757
+ label.appendChild(cb);
2758
+ return label;
2759
+ }
2760
+ // CSS.escape isn't on every old runtime; fall back to a minimal escape
2761
+ // for the characters that show up in a UUID-shaped collectionId.
2762
+ function cssEscape(value) {
2763
+ if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function')
2764
+ return CSS.escape(value);
2765
+ return value.replace(/["\\]/g, '\\$&');
2766
+ }
2767
+ // Put the storage banner in the slot, or take it out, to match the
2768
+ // current refresh-error map.
2769
+ //
2770
+ // Replaces only its own element, leaving the file-access and
2771
+ // user-scripts banners alone: those are probed on render (and the
2772
+ // user-scripts one on returning to the tab), and re-running them on every
2773
+ // repaint would mean an API call per structural storage write.
2774
+ const STORAGE_BANNER_ID = 'storage-banner';
2775
+ async function syncStorageBanner(liveIds) {
2776
+ document.getElementById(STORAGE_BANNER_ID)?.remove();
2777
+ const banner = await buildStorageBanner(liveIds);
2778
+ if (!banner)
2779
+ return;
2780
+ banner.id = STORAGE_BANNER_ID;
2781
+ bannerSlot.appendChild(banner);
2782
+ }
2783
+ // The banner for "a refresh was read but couldn't be saved", or null
2784
+ // when every collection saved fine.
2785
+ //
2786
+ // Gated on an actual failure rather than on the usage number alone. A
2787
+ // profile sitting at 95% is working perfectly well, and a banner that
2788
+ // appears before anything has gone wrong trains the reader to ignore
2789
+ // it. Once a save HAS failed, the usage figure is what turns the report
2790
+ // into something actionable, so it's fetched then and not before.
2791
+ async function buildStorageBanner(liveIds) {
2792
+ // Counted against the collections actually on the page. A removal
2793
+ // racing a failing refresh of the same collection can leave an entry
2794
+ // behind (the prune runs before the failure is recorded), and a banner
2795
+ // pointing at a section that isn't there would be unexplainable.
2796
+ if (!Object.keys(currentRefreshErrors).some((id) => liveIds.has(id)))
2797
+ return null;
2798
+ const div = document.createElement('div');
2799
+ div.className = 'banner';
2800
+ const usage = await readLocalStorageUsage();
2801
+ const full = usage !== null && usage.used >= usage.quota * STORAGE_NEARLY_FULL;
2802
+ const heading = document.createElement('strong');
2803
+ // Never singular: on an exhausted quota every collection fails, which
2804
+ // is the case this banner exists for.
2805
+ heading.textContent = full
2806
+ ? 'SourceMonkey is out of storage space.'
2807
+ : 'Some collections could not be saved.';
2808
+ div.appendChild(heading);
2809
+ const explain = document.createElement('p');
2810
+ explain.textContent = full
2811
+ ? 'Collections below were read successfully, but Chrome refused to '
2812
+ + 'store them, so their scripts are still running the last version '
2813
+ + 'that fit. Removing a collection you no longer need (or trimming '
2814
+ + 'a very large one) frees the space to save them.'
2815
+ // The non-full branch is genuinely unexplained, so it says so
2816
+ // rather than guessing a cause the reader would then chase.
2817
+ : 'Collections below were read successfully, but Chrome refused to '
2818
+ + 'store them, so their scripts are still running the last version '
2819
+ + 'that saved. Storage is not full, so the reason is in the message '
2820
+ + 'on each collection.';
2821
+ div.appendChild(explain);
2822
+ if (usage) {
2823
+ const detail = document.createElement('p');
2824
+ detail.className = 'banner-detail';
2825
+ detail.textContent =
2826
+ `Using ${formatStorageUsage(usage.used, usage.quota)}`;
2827
+ div.appendChild(detail);
2828
+ }
2829
+ return div;
2830
+ }
2831
+ // Put the "Allow User Scripts" banner in the slot, or take it out, to
2832
+ // match a fresh probe. Placed after the file-access banner and before the
2833
+ // storage one, since a recheck can add it after both are drawn.
2834
+ const USER_SCRIPTS_BANNER_ID = 'user-scripts-banner';
2835
+ // The last probe's answer, null before the first.
2836
+ let userScriptsUsable = null;
2837
+ async function syncUserScriptsBanner() {
2838
+ const state = await probeUserScriptsApi();
2839
+ const wasUsable = userScriptsUsable;
2840
+ userScriptsUsable = state.usable;
2841
+ const old = document.getElementById(USER_SCRIPTS_BANNER_ID);
2842
+ if (state.usable) {
2843
+ old?.remove();
2844
+ // Turned on while this page was open: nothing is registered yet,
2845
+ // and nothing else would tell the worker to register it.
2846
+ if (wasUsable === false)
2847
+ await syncAfterUserScriptsAllowed();
2848
+ return;
2849
+ }
2850
+ const banner = renderUserScriptsBanner(state.error);
2851
+ banner.id = USER_SCRIPTS_BANNER_ID;
2852
+ if (old)
2853
+ old.replaceWith(banner);
2854
+ else
2855
+ bannerSlot.insertBefore(banner, document.getElementById(STORAGE_BANNER_ID));
2856
+ }
2857
+ // Recheck when the person comes back, so the banner goes away once
2858
+ // they've turned the toggle on in the extension settings, without a
2859
+ // reload. Coming back to this tab fires `visibilitychange`; coming back
2860
+ // to this window from another one fires only `focus`. Both run one
2861
+ // recheck, since a return can fire both.
2862
+ //
2863
+ // Not before the first render is done: it draws the banners in order,
2864
+ // and a recheck finishing in the middle would put this one first.
2865
+ let recheckOnReturn = null;
2866
+ function recheckUserScriptsWhenBack() {
2867
+ if (recheckOnReturn !== null)
2868
+ return;
2869
+ recheckOnReturn = setTimeout(() => {
2870
+ recheckOnReturn = null;
2871
+ initialRenderDone.then(syncUserScriptsBanner).catch((err) => {
2872
+ console.info('[SourceMonkey] user scripts recheck failed', describeError(err));
2873
+ });
2874
+ }, 50);
2875
+ }
2876
+ window.addEventListener('focus', recheckUserScriptsWhenBack);
2877
+ document.addEventListener('visibilitychange', () => {
2878
+ if (document.visibilityState === 'visible')
2879
+ recheckUserScriptsWhenBack();
2880
+ });
2881
+ function renderUserScriptsBanner(error) {
2882
+ const div = document.createElement('div');
2883
+ // Red: nothing runs until it's fixed. The popup's bar matches.
2884
+ div.className = 'banner banner-error';
2885
+ const heading = document.createElement('strong');
2886
+ heading.textContent = USER_SCRIPTS_OFF_HEADING;
2887
+ div.appendChild(heading);
2888
+ // The raw API error, so a failure other than the toggle being off
2889
+ // (a revoked permission, say) isn't masked by the toggle advice.
2890
+ if (error) {
2891
+ const detail = document.createElement('p');
2892
+ detail.className = 'banner-detail';
2893
+ detail.textContent = `Chrome reported: ${error}`;
2894
+ div.appendChild(detail);
2895
+ }
2896
+ const fix = userScriptsFixLine('p');
2897
+ div.appendChild(fix);
2898
+ return div;
2899
+ }
2900
+ function makeMessage(className, text) {
2901
+ const p = document.createElement('p');
2902
+ p.className = className;
2903
+ p.textContent = text;
2904
+ return p;
2905
+ }