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,3672 @@
1
+ // Userscript viewer. Loaded as chrome-extension://…/userscript.html in
2
+ // one of two modes, chosen by the query string:
3
+ //
4
+ // ?script=<scriptId> — an installed script. Hydrates from the
5
+ // persistent store (chrome.storage.local)
6
+ // rather than re-reading the file, so the
7
+ // content always matches what the collections page
8
+ // shows.
9
+ // ?preview=<url> — a script that is NOT installed (yet). Fetches
10
+ // it, shows exactly the same panels, and offers
11
+ // Install / Update. This is where a captured
12
+ // `.user.js` navigation lands (see
13
+ // `install-capture.ts`) and where the collections
14
+ // page sends a single-file Add.
15
+ //
16
+ // Both modes render: a heading (favicon + location), a controls row,
17
+ // the details panel (`script-details.ts`), and the tabbed code view
18
+ // (`code-tabs.ts`). Preview swaps the Enable/Refresh controls for the
19
+ // install actions and has no Log tab; installed scripts have no
20
+ // install actions.
21
+ //
22
+ // The page never runs the code it shows. Bodies are rendered as text
23
+ // through Prism; the grammar extension that tints the metadata block
24
+ // lives in `prism-userscript.ts`, pulled in by `code-tabs.ts` — the
25
+ // module that actually calls `Prism.highlightElement`.
26
+ //
27
+ // See [docs/script-viewer.md] and [docs/install-capture.md].
28
+ import { ALREADY_INSTALLED_IN_TARGET, buildScriptFromText, describeError, distillMatching, getAllScriptMetas, getScriptMeta, getScript, getCollection, getCollections, hashSource, isScriptEnabled, isCollectionEnabled, validateHeader, localFileScope, } from './script-store.js';
29
+ import { allRelatedRows, contentFingerprint, enableChanges, findRelatedVersions, installOptionsFor, locationKey, otherActiveCopies, } from './script-identity.js';
30
+ import { buildInstallSelect, installChoiceBlocked, renderRelatedVersions, selectedInstallChoice, } from './related-versions.js';
31
+ import { enableTitle, watchSoloModifier } from './solo-click.js';
32
+ import { openCollectionDialog } from './collection-dialog.js';
33
+ import { openMoveDialog } from './move-dialog.js';
34
+ import { openCopyDialog } from './copy-dialog.js';
35
+ import { openDeleteDialog } from './delete-dialog.js';
36
+ import { setDeleteIcon } from './delete-icon.js';
37
+ import { setGlyphButton, setGlyphButtonBusy } from './glyph.js';
38
+ import { deletableCopyPath, readCopiesSetting } from './local-delete.js';
39
+ import { computeLogIds } from './script-log-id.js';
40
+ import { numberMap, readScriptNumbers, SCRIPT_NUMBERS_KEY } from './sticky-numbers.js';
41
+ import { attachFiles, distillRequires, fetchRequireForPreview, } from '../external-files.js';
42
+ import { getAllMaxLogLevels, getMaxLogLevel, getScriptLog, LOG_LEVEL_KEY_PREFIX, } from './script-log.js';
43
+ import { formatLogCsv, formatLogMessage, formatLogSource, formatLogStatus, formatLogTimestamp, logFileName, logStatusClass, LOG_COLUMNS, } from './log-format.js';
44
+ import { REJECTIONS_KEY, coerceRejectionMap, describeRejection, readRejections, } from './script-rejection.js';
45
+ import { appendWithSlashBreaks, compareUrl, scriptLabel, displayLocation, formatBytes, formatScriptSize, fullSource, requireBasename, scriptBasename, scriptStatusView, stripSriFragment, viewerUrl, } from '../script-display.js';
46
+ import { getValue, parseHeader, splitHeaderBody, } from '../userscript-header.js';
47
+ import { canonicalCollectionInput, fetchSingleFileText, isContainerCollection, isSmDevCollection, isSingleFileLocation, isFileUrl, isScratchUrl, isHttpUrl, LOCATION_SHAPES, normalizeCollectionInput, scriptAbsolutePath, collectionCanonicalUrl, isSingleFileInput, } from './directories.js';
48
+ import { COLLECTION_OFF_TITLE, } from './collection-display.js';
49
+ import { HEADING_SITE_ICONS, renderSiteIconInto } from './site-icon.js';
50
+ // Also the page's early heading paint — see src/extension/page-heading.ts. The
51
+ // module runs once, so importing it here does not repeat that.
52
+ import { headingPrefixEl, INSTALLED_HEADING, INSTALLED_TITLE, MISSING_LABEL, PREVIEW_HEADING, PREVIEW_TITLE, notePainted, setPageTitle, } from './page-heading.js';
53
+ import { installHomeLinkHandler } from './page-links.js';
54
+ import { MarkupResponseError } from '../markup-response.js';
55
+ import { resolveScriptResource } from '../file-loader.js';
56
+ import { installLocalRootsListener, localRootsReady, localSearchRoots, } from '../local-file-overrides.js';
57
+ import { installGithubAuthListener, loadGithubAuthToken, } from '../github-auth.js';
58
+ import { CAPTURED_PARAM, markInstallBypass } from './install-capture.js';
59
+ import { isFileSchemeAccessAllowed, renderFileAccessBanner } from './file-access.js';
60
+ import { checkScriptParses } from './parse-check-client.js';
61
+ import { describeDiagnostic, locationLabel, validLocation, } from '../diagnostic-location.js';
62
+ import { buildHash } from './script-url-hash.js';
63
+ import { dlog, installDebugLoggingListener, loadDebugLoggingFlag } from '../debug-log.js';
64
+ import { displayRefText, parseStackRefs } from '../stack-link.js';
65
+ import { remapStackRef } from '../source-map.js';
66
+ import { stackLayout } from '../composed-source.js';
67
+ import { buildRequireSourceUrl, buildSourceUrl } from '../script-source-url.js';
68
+ import { startPhaseTimer } from '../phase-timing.js';
69
+ import { addCollection, installScript, updateScript, clearScriptLog, enableWithin, forgetDeniedHost, removeScript, revokeConnectHost, setScriptEnabledExclusive, setScriptsEnabled, turnOffOtherCopies, } from './worker-client.js';
70
+ import { CodeTabsView } from './code-tabs.js';
71
+ import { makeCopyButton } from './copy-button.js';
72
+ import { markFileChanges, missingFiles, scriptFileTag } from './file-changes.js';
73
+ import { renderDetails } from './script-details.js';
74
+ import { connectGrantsKey, getConnectGrants, } from './connect-grants.js';
75
+ import { installSelectAll } from './select-all.js';
76
+ // The preview's fetch runs in the page, so the page needs its own copy
77
+ // of the cached GitHub token, and awaits it before fetching so an early
78
+ // load can't go out unauthenticated. Refresh runs in the worker, which
79
+ // waits for its own copy.
80
+ installGithubAuthListener();
81
+ const githubTokenReady = loadGithubAuthToken();
82
+ const params = new URLSearchParams(location.search);
83
+ // Which installed script the page is showing. Not `const`: the
84
+ // versions panel navigates between copies in place (see
85
+ // `navigateToScript`), which reassigns this and pushes a history
86
+ // entry rather than reloading the page.
87
+ let scriptId = params.get('script');
88
+ // The location to preview, exactly as it arrived: the URL the user
89
+ // clicked (for a captured navigation) or typed into Add). See
90
+ // the preview-mode section below for the normalized forms derived
91
+ // from it.
92
+ const previewInput = params.get('preview');
93
+ // True when the link capture put us here rather than the user asking
94
+ // for this preview. Gates the "it's a web page, get out of the way"
95
+ // escape below — see CAPTURED_PARAM.
96
+ const previewWasCaptured = params.get(CAPTURED_PARAM) === '1';
97
+ const filenameEl = document.getElementById('filename');
98
+ const controlsRowEl = document.getElementById('meta-row');
99
+ const enableLabelEl = document.getElementById('enable-label');
100
+ const enableCheckboxEl = document.getElementById('enable-checkbox');
101
+ const metaActionsEl = document.getElementById('meta-actions');
102
+ const refreshBtn = document.getElementById('refresh-btn');
103
+ // Built once with the idle label and restored to it after every
104
+ // refresh, so the two spellings can't drift.
105
+ const REFRESH_LABELS = { idle: 'Refresh', busy: 'Refreshing…' };
106
+ const bannerSlotEl = document.getElementById('banner-slot');
107
+ // Wraps the banner slot and the versions panel. Carries `merged` while
108
+ // the two are one green box — see `renderInstalledBanner`.
109
+ const statusBoxEl = document.getElementById('status-box');
110
+ const actionsEl = document.getElementById('preview-actions');
111
+ const installCalloutEl = document.getElementById('install-callout');
112
+ const installTargetLabel = document.getElementById('install-target-label');
113
+ const installTargetSlot = document.getElementById('install-target-slot');
114
+ const installBtn = document.getElementById('install-btn');
115
+ const openBtn = document.getElementById('open-btn');
116
+ const continueBtn = document.getElementById('continue-btn');
117
+ const cancelBtn = document.getElementById('cancel-btn');
118
+ const viewSourceBtn = document.getElementById('view-source-btn');
119
+ const messageEl = document.getElementById('message');
120
+ const summaryEl = document.getElementById('header-summary');
121
+ const versionsEl = document.getElementById('other-versions');
122
+ const tabsEl = document.getElementById('tabs');
123
+ const contentEl = document.getElementById('content');
124
+ const codePaneEl = document.getElementById('code-pane');
125
+ const logPaneEl = document.getElementById('log-pane');
126
+ const tabsView = new CodeTabsView({ tabsEl, codePaneEl, contentEl, logPaneEl }, { onShowLog: () => void renderLogPane() });
127
+ setGlyphButton(refreshBtn, '#sm-refresh', REFRESH_LABELS.idle);
128
+ refreshBtn.addEventListener('click', () => void onRefresh());
129
+ // Same modifier wiring as a panel row's checkbox, from the same helper:
130
+ // ctrl/cmd-click opts out of the exclusive sweep here too.
131
+ const takeControlsRowSolo = watchSoloModifier(enableCheckboxEl);
132
+ enableCheckboxEl.addEventListener('change', () => {
133
+ onToggleEnabled(enableCheckboxEl.checked).catch((err) => {
134
+ // Logged, not thrown: a dropped rejection lands in the extension's
135
+ // Errors panel. The boxes go back to what is stored.
136
+ console.info('[SourceMonkey] could not change whether this script is enabled:', describeError(err));
137
+ rehydrateAfterStorageChange().catch(() => undefined);
138
+ });
139
+ });
140
+ // The Location row's copy button. One long-lived element, appendChild'd
141
+ // into the dl on each render (appendChild moves it, so no duplication),
142
+ // which is what lets an in-flight "Copied" flash survive a repaint.
143
+ // Reads `currentFilenamePath` at click time.
144
+ //
145
+ // Copies the raw location, not the decoded form the row displays: a
146
+ // pasted URL has to still work in a terminal, an address bar, or the
147
+ // Add field.
148
+ const filenameCopyBtn = makeCopyButton({
149
+ label: 'Copy location to clipboard',
150
+ text: () => currentFilenamePath,
151
+ });
152
+ // Whether the per-extension "Allow access to file URLs" toggle is on.
153
+ // Probed once per page rather than per failure: the toggle lives on
154
+ // chrome://extensions and flipping it doesn't reach an already-open
155
+ // page, so the answer can't change under us. Caching it also keeps the
156
+ // "was it the toggle?" question answerable from the synchronous render
157
+ // path.
158
+ //
159
+ // Fired at module scope rather than inside `load()` so it runs
160
+ // alongside the rest of module init and is normally already settled by
161
+ // the time anything awaits it. This is on every viewer open, including
162
+ // the http scripts that can never need the answer, so it should not be
163
+ // the thing first paint waits on.
164
+ const fileAccessProbe = isFileSchemeAccessAllowed();
165
+ let fileAccessAllowed = true;
166
+ installLocalRootsListener();
167
+ // Same as the collections page: without the flag, this page's `dlog`
168
+ // and timing lines (an enable click's, a Refresh's) never print.
169
+ installDebugLoggingListener();
170
+ void loadDebugLoggingFlag();
171
+ // The header bar's icon and product name are a link home. Delegated, so
172
+ // it survives the heading repaints an in-place copy switch does.
173
+ installHomeLinkHandler();
174
+ void load();
175
+ async function load() {
176
+ fileAccessAllowed = await fileAccessProbe;
177
+ // Stand-in resolution (the preview's own parse check, and the icon
178
+ // slot) reads the configured roots synchronously, so hydrate the
179
+ // setting before either runs.
180
+ await localRootsReady();
181
+ if (scriptId) {
182
+ await loadStored();
183
+ return;
184
+ }
185
+ if (previewInput) {
186
+ await loadPreview();
187
+ return;
188
+ }
189
+ showMissing('Missing `script` or `preview` query parameter.');
190
+ }
191
+ // ---------- Installed-script mode ----------
192
+ // Keep the displayed Log ID current: the service worker numbers a new
193
+ // script just after it is added, and an unnumbered script's `#N` moves
194
+ // when another sharing its basename comes or goes. Listen for any
195
+ // script:* mutation and for the numbers row, and re-derive.
196
+ chrome.storage.onChanged.addListener((changes, areaName) => {
197
+ // Before the `scriptId` gate below: the panel's Status column speaks
198
+ // for every copy it draws, and a PREVIEW draws copies too. Everything
199
+ // after the gate is about the one installed script this page is
200
+ // showing, which a preview doesn't have.
201
+ if (areaName === 'session' && panelStatusChanged(changes)) {
202
+ repaintPanelFromLastArgs();
203
+ }
204
+ if (!scriptId)
205
+ return;
206
+ if (areaName === 'local') {
207
+ // Repaint on any script:* or script-number mutation (this script,
208
+ // or a sibling whose add/remove shifts a `#N`) AND on a change to THIS
209
+ // script's parent collection. A collection enable/disable or rename
210
+ // done on the collections page or in another tab has to reach the
211
+ // viewer's Enable toggle and Collection row, not just this script's
212
+ // own Refresh.
213
+ const collectionKey = currentScript ? `collection:${currentScript.collectionId}` : null;
214
+ let touched = false;
215
+ for (const key of Object.keys(changes)) {
216
+ if (key.startsWith('script:') || key === SCRIPT_NUMBERS_KEY || key === connectGrantsKey(scriptId)) {
217
+ touched = true;
218
+ break;
219
+ }
220
+ // A collection row is written on every refresh attempt, successful or
221
+ // not (`lastRefreshedAt`, `scanError`), and none of that shows
222
+ // here. Re-render only for the fields this page actually reads,
223
+ // or a collection stuck retrying a failing scan would re-render every
224
+ // viewer open on it, on every retry, with nothing changing.
225
+ if (collectionKey && key === collectionKey && sourceFieldsShownHereChanged(changes[key])) {
226
+ touched = true;
227
+ break;
228
+ }
229
+ }
230
+ if (touched)
231
+ void rehydrateAfterStorageChange();
232
+ return;
233
+ }
234
+ if (areaName === 'session') {
235
+ // Track THIS script's max-log-level row so the Status cell ramps
236
+ // to WARNING / ERROR (or back down to OK) as entries arrive or
237
+ // the log is cleared. Independent of the log-pane repaint below
238
+ // because the Status row is visible on every tab.
239
+ const levelKey = `loglevel:${scriptId}`;
240
+ if (levelKey in changes) {
241
+ const nextLevel = changes[levelKey]?.newValue;
242
+ currentLogLevel = (nextLevel === 'info' || nextLevel === 'warning' || nextLevel === 'error')
243
+ ? nextLevel
244
+ : undefined;
245
+ if (currentScript)
246
+ repaintStatusRow(currentScript);
247
+ }
248
+ // Same for the injector's rejection map: a sync that finally places
249
+ // this script (or newly refuses it) has to move the Status row and
250
+ // the message bar, both of which are visible from every tab.
251
+ if (REJECTIONS_KEY in changes) {
252
+ currentRejection = coerceRejectionMap(changes[REJECTIONS_KEY]?.newValue)[scriptId];
253
+ if (currentScript) {
254
+ repaintStatusRow(currentScript);
255
+ paintScriptMessage(currentScript);
256
+ }
257
+ }
258
+ // Repaint the log pane only when THIS script's log changed and the
259
+ // user is currently looking at it — the pane is hidden behind a
260
+ // code tab the rest of the time, so we'd just be doing work the
261
+ // user can't see.
262
+ const logKey = `log:${scriptId}`;
263
+ if (!(logKey in changes))
264
+ return;
265
+ if (logPaneEl.hidden)
266
+ return;
267
+ const newValue = changes[logKey].newValue;
268
+ const entries = Array.isArray(newValue) ? newValue : [];
269
+ // Bump the paint epoch so an in-flight renderLogPane() read from
270
+ // before this event can't overwrite us with its (now stale) array.
271
+ logPaintEpoch++;
272
+ paintLog(entries);
273
+ }
274
+ });
275
+ // Does a `collection:*` delta touch anything this viewer shows? The
276
+ // Enable toggle reads the collection's enabled state and the Collection
277
+ // row reads its display name and path; nothing else on the page comes
278
+ // from that row. An add or a removal always counts — the re-read
279
+ // handles both.
280
+ function sourceFieldsShownHereChanged(change) {
281
+ const prev = change.oldValue;
282
+ const next = change.newValue;
283
+ if (!prev || !next)
284
+ return true;
285
+ return (isCollectionEnabled(prev) !== isCollectionEnabled(next) ||
286
+ (prev.displayName ?? '') !== (next.displayName ?? '') ||
287
+ prev.path !== next.path);
288
+ }
289
+ // Swap the Status row's inner content without rebuilding the rest of
290
+ // the details panel — the row sits inside a <dl> we don't want to
291
+ // thrash on every log append. Find the existing meta-status anchor
292
+ // (or any node carrying the class) and replace it with a freshly
293
+ // computed one.
294
+ function repaintStatusRow(script) {
295
+ const prior = summaryEl.querySelector('.meta-status');
296
+ if (!prior)
297
+ return;
298
+ prior.replaceWith(buildStatusCell(script));
299
+ }
300
+ // The viewer's not-found line: this id names no stored row. One copy,
301
+ // because two surfaces reach it — the initial load and the storage
302
+ // watcher — and a message that drifted between them would read as two
303
+ // different problems.
304
+ const SCRIPT_NOT_IN_STORAGE = 'Script not found in storage. Open it from the collections page.';
305
+ async function rehydrateAfterStorageChange() {
306
+ if (!scriptId)
307
+ return;
308
+ // Same staleness rule as `loadStored`: an in-page navigation may have
309
+ // moved the page on while this read was in flight.
310
+ const wanted = scriptId;
311
+ // If THIS script was removed, drop into the not-found state.
312
+ const script = await getScript(wanted);
313
+ if (scriptId !== wanted)
314
+ return;
315
+ if (!script) {
316
+ // A removal made here that is already on its way to another copy.
317
+ if (swappingAfterRemoval)
318
+ return;
319
+ // Not consumed here: this path can run more than once for a single
320
+ // removal (our own call, then the storage event), and the second
321
+ // pass must not downgrade the wording. `render` clears the flag
322
+ // instead — once a script is on screen, any earlier removal is
323
+ // history and a later disappearance is someone else's.
324
+ showMissing(removedHereMessage !== null
325
+ // Anything the removal could not finish is painted WITH it, for
326
+ // the same reason: a message set afterwards would be wiped by
327
+ // the next pass.
328
+ ? [removedHereMessage, ...removedHereDetail].join(' ')
329
+ : SCRIPT_NOT_IN_STORAGE);
330
+ return;
331
+ }
332
+ // Re-read the parent collection too: its enabled state greys the
333
+ // Enable toggle and its display name shows in the Collection row, and
334
+ // either can change from the collections page or another tab while
335
+ // this viewer is open. render() reads these from the cached values.
336
+ const collection = await getCollection(script.collectionId);
337
+ if (scriptId !== wanted)
338
+ return;
339
+ cacheCollection(collection);
340
+ await refreshConnectAnswers(wanted);
341
+ // One read of the whole store for both the log label and the versions
342
+ // table, rather than one each (each was measured at 200 to 400 ms on a
343
+ // 10 MB store). Read last, just before the paint, so a paint started
344
+ // during the awaits above can't have read later than it. Failing
345
+ // that, each reads for itself, as before.
346
+ const numbers = await readScriptNumbers();
347
+ const snapshot = await readStoreSnapshot().catch(() => undefined);
348
+ if (scriptId !== wanted)
349
+ return;
350
+ if (snapshot)
351
+ currentLogId = logIdOf(wanted, snapshot.scripts, numbers);
352
+ else
353
+ await refreshLogId(wanted);
354
+ if (scriptId !== wanted)
355
+ return;
356
+ render(script, snapshot);
357
+ // render() wipes the flash overlay via `textContent =` and may
358
+ // have clamped the tab index to 0 if a require disappeared.
359
+ // Re-anchor highlight + URL without scrolling the code pane.
360
+ tabsView.applyHash(false);
361
+ }
362
+ // Collection-enabled state is needed for greying the toggle. We cache
363
+ // it so onToggle doesn't have to re-read storage. collectionPath is
364
+ // cached for the same reason — render() builds the absolute filename
365
+ // from it. collectionRow holds the whole StoredCollection (when found)
366
+ // so the Collection row can render its bubble + display name without a
367
+ // second read.
368
+ let collectionEnabled = true;
369
+ let collectionPath = '';
370
+ let collectionRow = null;
371
+ // Single place the three cached collection values are derived, so the
372
+ // load path and the live re-hydrate can't fall out of step on what a
373
+ // missing collection means (treated as enabled, with no path and no row
374
+ // to render).
375
+ function cacheCollection(collection) {
376
+ collectionEnabled = collection ? isCollectionEnabled(collection) : true;
377
+ collectionPath = collection?.path ?? '';
378
+ collectionRow = collection ?? null;
379
+ }
380
+ // Path currently shown in the Location cell. The copy button reads
381
+ // this rather than the cell's textContent, which would also break if
382
+ // the dl ever wraps the path across multiple text nodes.
383
+ let currentFilenamePath = '';
384
+ // This script's log ID. Computed at load(), re-derived after refresh,
385
+ // and kept fresh while the viewer is open by listening to
386
+ // chrome.storage.onChanged for any script:* mutation (another tab
387
+ // adding/removing a sibling with the same basename would otherwise
388
+ // leave a stale `#N` on screen).
389
+ let currentLogId = '';
390
+ // The injected entry's line layout, recomputed whenever the tab strip
391
+ // is rebuilt, plus the sourceURL every frame from that entry carries.
392
+ // Together they let the Log tab map a generated stack line back to the
393
+ // `@require` (or body) it was written in — see `remapLogRef`. Empty
394
+ // segments mean "nothing to undo": either the script has no
395
+ // `@require`s, or we're in preview mode with nothing injected at all.
396
+ let stackSegments = [];
397
+ let stackEntryUrl = '';
398
+ // Lines in the user program. With no `@require`s that is the whole
399
+ // entry except its last line, which holds our closer (`})();});`) and
400
+ // shows up in every synchronous throw's stack as the arrow's call site.
401
+ // A ref past this line is ours, not the user's.
402
+ let stackBodyLineCount = 0;
403
+ // Identity of the script state `stackSegments` was computed from, so a
404
+ // re-render for an unrelated reason doesn't redo the concatenation.
405
+ let stackLayoutKey = '';
406
+ // Most recently rendered script. The session-storage listener calls
407
+ // paintLog without re-reading the script, so we capture the latest
408
+ // here so log-pane linkification can consult the tab list without
409
+ // re-reading storage.
410
+ let currentScript = null;
411
+ // Highest level any log entry has carried so far for this script.
412
+ // Used to promote the Status row to WARNING / ERROR. Refreshed on
413
+ // load and from the storage.session.onChanged listener so the Status
414
+ // row tracks the Log pane without a full re-render.
415
+ let currentLogLevel;
416
+ // Chrome's reason for refusing to register THIS script, while it is
417
+ // refusing — the script loaded fine but never runs. Drives the REJECTED
418
+ // status and the message bar. Undefined in the normal case; kept fresh
419
+ // from the same storage.session listener as `currentLogLevel`.
420
+ let currentRejection;
421
+ async function loadStored() {
422
+ if (!scriptId)
423
+ return;
424
+ // Captured because in-page navigation can start a second hydrate
425
+ // while this one is still reading storage (two quick clicks in the
426
+ // panel). `scriptId` is the single source of truth for which copy the
427
+ // page is showing, so a hydrate that no longer matches it has lost —
428
+ // rendering it anyway would leave the URL naming one script and the
429
+ // page showing another.
430
+ const wanted = scriptId;
431
+ const script = await getScript(wanted);
432
+ if (scriptId !== wanted)
433
+ return;
434
+ if (!script) {
435
+ showMissing(SCRIPT_NOT_IN_STORAGE);
436
+ return;
437
+ }
438
+ const collection = await getCollection(script.collectionId);
439
+ // Checked BEFORE the write, not after: `cacheCollection` and
440
+ // `refreshLogId` both set module state that the winning hydrate's
441
+ // later renders read, so a loser that wrote and then bailed would
442
+ // leave the page rendering the wrong collection.
443
+ if (scriptId !== wanted)
444
+ return;
445
+ cacheCollection(collection);
446
+ await refreshLogId(wanted);
447
+ await refreshConnectAnswers(wanted);
448
+ if (scriptId !== wanted)
449
+ return;
450
+ // Hydrate the max log level and any registration rejection before
451
+ // render() so the first paint of the Status row already reflects
452
+ // WARNING / ERROR / REJECTED. Without this the row would briefly
453
+ // show OK and then flip when the storage listener fires.
454
+ currentLogLevel = await getMaxLogLevel(scriptId);
455
+ currentRejection = (await readRejections())[scriptId];
456
+ render(script);
457
+ // The tab view seeds its own selection from `location.hash` on the
458
+ // first setTabs(); this applies the line highlight and normalizes
459
+ // the URL.
460
+ tabsView.applyHash(true);
461
+ }
462
+ // The user's answers to the connection prompt for the script shown,
463
+ // for the Network access row. Read along with the Log ID, so `render`
464
+ // stays synchronous.
465
+ let currentConnectAnswers = null;
466
+ async function refreshConnectAnswers(id) {
467
+ try {
468
+ currentConnectAnswers = await getConnectGrants(id);
469
+ }
470
+ catch {
471
+ currentConnectAnswers = null;
472
+ }
473
+ }
474
+ // Takes the id rather than reading `scriptId`: the callers work on an
475
+ // id they captured before their first await, and an in-page navigation
476
+ // can move `scriptId` on underneath them.
477
+ async function refreshLogId(id) {
478
+ const all = await getAllScriptMetas();
479
+ currentLogId = logIdOf(id, all, await readScriptNumbers());
480
+ }
481
+ function logIdOf(id, all, numbers) {
482
+ return computeLogIds(all, numberMap(numbers)).get(id) ?? '';
483
+ }
484
+ async function onRefresh() {
485
+ if (!scriptId)
486
+ return;
487
+ // Same capture as `loadStored`: the user can pick another copy from
488
+ // the panel while the refetch is in flight.
489
+ const wanted = scriptId;
490
+ setGlyphButtonBusy(refreshBtn, true, REFRESH_LABELS);
491
+ try {
492
+ const next = await refreshShownScript(wanted);
493
+ if (!next)
494
+ return;
495
+ await refreshLogId(wanted);
496
+ await refreshConnectAnswers(wanted);
497
+ render(next);
498
+ // Re-anchor highlight + URL after the repaint. Scroll skipped so
499
+ // a Refresh doesn't yank the user's scroll position.
500
+ tabsView.applyHash(false);
501
+ }
502
+ finally {
503
+ setGlyphButtonBusy(refreshBtn, false, REFRESH_LABELS);
504
+ }
505
+ }
506
+ // Refresh `wanted`, the copy this page is showing. Shared by the page's
507
+ // own Refresh button and the versions panel's row buttons (its row for
508
+ // this copy, and "Refresh all"), so both handle the row being dropped
509
+ // the same way.
510
+ //
511
+ // Returns the refreshed row for the caller to paint, or null when
512
+ // there is nothing left for it to do: the page has moved on to another
513
+ // copy (the user picked one meanwhile, or the check dropped this one
514
+ // and the page swapped to its successor), or has been rehydrated into
515
+ // the not-found state.
516
+ async function refreshShownScript(wanted) {
517
+ // Routed through the shared updater: if a background opportunistic
518
+ // check is already in flight for this script, we wait for it
519
+ // instead of starting a second fetch.
520
+ // 'force': this is a real Refresh click, so check upstream now
521
+ // rather than waiting out the probe interval.
522
+ // A local script's check can find its file gone and drop the row
523
+ // itself (docs/file-changes.md) — the outcome the `deleted`
524
+ // chip's tooltip promised. The storage event for that removal can
525
+ // reach `rehydrateAfterStorageChange` before or after the worker's
526
+ // answer, so the wording it should paint is set in advance rather
527
+ // than after: otherwise the generic not-found line lands first,
528
+ // and reads as a storage fault. `render` clears it again the
529
+ // moment the check comes back with a row, which is the usual case.
530
+ //
531
+ // Set only when the file IS gone right now (the same probe the
532
+ // chip uses), so a row that goes away for some other reason during
533
+ // the check — removed from another tab, say — isn't blamed on its
534
+ // file.
535
+ const local = !!currentScript && isFileUrl(currentScript.fileUrl)
536
+ && (await missingFiles([currentFilenamePath])).has(currentFilenamePath);
537
+ if (local)
538
+ removedHereMessage = REMOVED_FILE_GONE;
539
+ // And where the page goes if the row does get dropped: the next
540
+ // copy in the versions table, the same way the panel's trash can
541
+ // moves on rather than stranding the page on the not-found line.
542
+ // Picked before the check, for the reason the trash can gives —
543
+ // the removal's storage event reaches the listener first, and the
544
+ // flag is what holds back its not-found paint.
545
+ const successor = local ? await survivingRowAfter(lastRelatedRows, wanted) : undefined;
546
+ if (successor)
547
+ swappingAfterRemoval = true;
548
+ let next;
549
+ try {
550
+ // The worker says whether the row is still there; the page reads it
551
+ // itself, bodies and all, rather than have them sent back.
552
+ next = await updateScript(wanted, 'manual', 'force') ? (await getScript(wanted)) ?? null : null;
553
+ }
554
+ catch (err) {
555
+ // No verdict, so no claim: a later removal by someone else must
556
+ // not be painted as this refresh's doing.
557
+ // Only our own claim: a trash can may have set its own meanwhile.
558
+ if (local && removedHereMessage === REMOVED_FILE_GONE)
559
+ removedHereMessage = null;
560
+ if (successor)
561
+ swappingAfterRemoval = false;
562
+ throw err;
563
+ }
564
+ if (scriptId !== wanted) {
565
+ if (successor)
566
+ swappingAfterRemoval = false;
567
+ return null;
568
+ }
569
+ if (!next) {
570
+ if (successor) {
571
+ try {
572
+ await swapToScript(successor, { replace: true });
573
+ }
574
+ finally {
575
+ swappingAfterRemoval = false;
576
+ }
577
+ return null;
578
+ }
579
+ // Driven from here too: `removeScript` writes nothing when the
580
+ // row was already gone, so there may be no event to wait for.
581
+ await rehydrateAfterStorageChange();
582
+ return null;
583
+ }
584
+ // The row is back, so the claim is withdrawn here and not left to
585
+ // `render`: the panel's caller paints nothing itself, and a check
586
+ // that found nothing to write leaves the storage listener with no
587
+ // paint to clear it with either.
588
+ if (local && removedHereMessage === REMOVED_FILE_GONE)
589
+ removedHereMessage = null;
590
+ if (successor)
591
+ swappingAfterRemoval = false;
592
+ return next;
593
+ }
594
+ // A connection grant the worker couldn't change. The Connections list
595
+ // redraws from storage, so it still shows what is saved.
596
+ function logGrantFailure(err) {
597
+ console.info('[SourceMonkey] could not change a connection permission:', describeError(err));
598
+ }
599
+ // The controls-row toggle. Same radio-button rule as the panel's rows:
600
+ // enabling this copy turns off every other copy that is running, since
601
+ // two enabled copies of one script both register and both run.
602
+ //
603
+ // The worker finds the related copies and applies the rule
604
+ // (`setScriptEnabledExclusive`), from its row cache. The panel's rows,
605
+ // as last drawn, only say which boxes to flip before the write lands.
606
+ async function onToggleEnabled(enabled) {
607
+ if (!scriptId)
608
+ return;
609
+ const solo = takeControlsRowSolo();
610
+ const changes = enableChanges(scriptId, enabled, enabled && !solo ? lastRelatedRows : []);
611
+ // Before the awaits: both checkboxes are showing an intent, and a
612
+ // radio-button pair that flips one now and the other after a round
613
+ // trip through storage reads as a bug.
614
+ paintEnabledOptimistically(changes);
615
+ await setScriptEnabledExclusive(scriptId, enabled, solo);
616
+ const next = await getScript(scriptId);
617
+ if (next) {
618
+ render(next);
619
+ tabsView.applyHash(false);
620
+ }
621
+ }
622
+ // Write one click's worth of enable changes from the versions table.
623
+ //
624
+ // `setScriptsEnabled`, not a loop of `setScriptEnabled`: the bulk call
625
+ // lands as ONE `chrome.storage.local.set`, so it fires one
626
+ // `storage.onChanged`. A loop fired one per copy, and every one of them
627
+ // rehydrated this page from a half-applied state — the user watched the
628
+ // Enabled checkbox flip back and forth as the batch went through. It
629
+ // also spares the background worker a registration sync per copy.
630
+ //
631
+ // An enable that turns other copies off (`related` set) is planned
632
+ // again inside the write lock, from those copies' stored state (see
633
+ // `enableWithin`): `changes` was worked out from what this page last
634
+ // drew, which a write that landed since has made stale.
635
+ async function applyEnableChanges(changes, related) {
636
+ const clicked = changes[0];
637
+ if (related && clicked?.enabled) {
638
+ await enableWithin(clicked.scriptId, [clicked.scriptId, ...related]);
639
+ return;
640
+ }
641
+ await setScriptsEnabled(changes.map((c) => ({ id: c.scriptId, enabled: c.enabled })));
642
+ }
643
+ // One write, so one failure — there is no half-applied batch to
644
+ // enumerate. A single change still names the copy and the direction,
645
+ // because that message can appear next to several identical-looking
646
+ // rows.
647
+ function describeEnableFailure(changes, scripts, err) {
648
+ if (changes.length === 1) {
649
+ const verb = changes[0].enabled ? 'enable' : 'disable';
650
+ return `Could not ${verb} ${describeRelatedScript(scripts, changes[0].scriptId)}: ${describeError(err)}`;
651
+ }
652
+ return `Could not change which copy is enabled: ${describeError(err)}`;
653
+ }
654
+ // Show an enable batch the moment it is decided, on every checkbox that
655
+ // carries part of it — the page's own toggle and any panel row for the
656
+ // same script. The write then lands underneath, and the repaint both
657
+ // callers run afterwards redraws from stored state.
658
+ //
659
+ // Purely cosmetic: nothing reads these back, and every path that calls
660
+ // this repaints unconditionally — including when the write turns out
661
+ // to be a no-op, which fires no storage event of its own. So a paint
662
+ // that got ahead of the truth is always corrected.
663
+ function paintEnabledOptimistically(changes) {
664
+ for (const change of changes) {
665
+ if (change.scriptId === scriptId)
666
+ enableCheckboxEl.checked = change.enabled;
667
+ const row = versionsEl.querySelector(`tr[data-script-id="${CSS.escape(change.scriptId)}"] input[type=checkbox]`);
668
+ if (row)
669
+ row.checked = change.enabled;
670
+ }
671
+ }
672
+ // `snapshot`, when the caller just read the store, is what the versions
673
+ // table paints from instead of reading it again.
674
+ function render(script, snapshot) {
675
+ currentScript = script;
676
+ // A script is on screen, so no removal is pending; the next time a
677
+ // row goes missing it is someone else's doing. See
678
+ // `removedHereMessage`.
679
+ removedHereMessage = null;
680
+ removedHereDetail = [];
681
+ controlsRowEl.hidden = false;
682
+ // A missing collection row reads as ENABLED, matching
683
+ // `cacheCollection`'s `collectionEnabled` default and
684
+ // `renderHeading`'s own — three spellings of the same "row not found"
685
+ // state should not disagree.
686
+ const label = scriptLabel(script.header, script.relativePath);
687
+ renderHeading(INSTALLED_HEADING, label, displayLocation(script.relativePath), script, collectionRow ? localFileScope(collectionRow) : { enabled: true });
688
+ setPageTitle(INSTALLED_TITLE, label);
689
+ // Rebuilt per render rather than updated in place: both controls
690
+ // close over the script row they were built from.
691
+ metaActionsEl.replaceChildren(...makeScriptActions(script, collectionRow));
692
+ enableCheckboxEl.checked = isScriptEnabled(script);
693
+ enableCheckboxEl.disabled = !collectionEnabled;
694
+ enableLabelEl.classList.toggle('toggle-disabled', !collectionEnabled);
695
+ // The plain wording; `paintRelatedVersions` adds the multi-enable
696
+ // hint when there are other copies for the click to sweep.
697
+ enableLabelEl.title = collectionEnabled ? enableTitle(false) : COLLECTION_OFF_TITLE;
698
+ // Identity the script's own diagnostics name it by. Set before the
699
+ // tabs so `appendDiagnostic` below can resolve a location to tab 0.
700
+ mainDiagFile = script.relativePath;
701
+ // Same question the preview asks after a failed fetch, for the same
702
+ // reason: a local script we couldn't read has most likely hit the
703
+ // file-access toggle. Cleared first because `render` re-runs on every
704
+ // storage change — the banner must not outlive the error that raised
705
+ // it (a Refresh that succeeds clears `refreshError`).
706
+ //
707
+ // Reload is the wrong advice here: this row is already in storage, so
708
+ // re-opening the page re-renders the same cached copy and the same
709
+ // error. Refresh is what re-reads.
710
+ bannerSlotEl.replaceChildren();
711
+ if (readFailed(script)) {
712
+ showFileAccessBannerIfBlocked(script.fileUrl, 'then click Refresh');
713
+ }
714
+ // Resolved location for the script: an absolute filesystem path for
715
+ // file:// collections, the github page URL for github collections, or
716
+ // the resolved URL for non-github HTTP collections. Skip the row
717
+ // entirely if the collection row is gone — a bare relative path under
718
+ // a "Location" label would be misleading.
719
+ currentFilenamePath = collectionPath
720
+ ? scriptAbsolutePath(collectionPath, script.relativePath)
721
+ : '';
722
+ // Anchor href: for file:// collections we use the file:// URL (the
723
+ // display string is the bare fs path, not navigable). For everything
724
+ // else display = href, so the user clicks the same URL they'd copy
725
+ // from the row.
726
+ const locationHref = isFileUrl(script.fileUrl) ? script.fileUrl : currentFilenamePath;
727
+ renderDetails(summaryEl, {
728
+ header: script.header,
729
+ files: { requires: script.requires, resources: script.resources, pushed: isScratchUrl(script.fileUrl) },
730
+ scriptUrl: script.fileUrl,
731
+ logId: currentLogId,
732
+ collection: collectionRow,
733
+ collectionTrailing: collectionRow ? makeMoveControl(script, collectionRow) : undefined,
734
+ location: currentFilenamePath
735
+ ? {
736
+ text: displayLocation(currentFilenamePath),
737
+ href: locationHref,
738
+ unopenable: isScratchUrl(script.fileUrl),
739
+ trailing: filenameCopyBtn,
740
+ localFile: collectionPath ? scriptFileTag(script, collectionPath) ?? undefined : undefined,
741
+ // This href is a `.user.js` URL for nearly every installed
742
+ // script, so a plain navigation would be captured and land
743
+ // the user in a preview of the script they are already
744
+ // looking at. Open it the same way the preview does: bypass
745
+ // first, then a new tab. (A github collection's blob URL ends
746
+ // in `.user.js` too, and is an HTML page, so it would land on
747
+ // the "not an installable userscript" banner.)
748
+ onActivate: () => navigateRaw(locationHref),
749
+ }
750
+ : undefined,
751
+ size: formatScriptSize(script),
752
+ status: buildStatusCell(script),
753
+ versionProbes: script.versionProbes,
754
+ ...(currentConnectAnswers ? {
755
+ connect: {
756
+ grants: currentConnectAnswers,
757
+ // The storage change repaints the row.
758
+ revoke: (host) => void revokeConnectHost(script.id, host).catch(logGrantFailure),
759
+ forgetDenied: (host) => void forgetDeniedHost(script.id, host).catch(logGrantFailure),
760
+ },
761
+ } : {}),
762
+ });
763
+ // After the panel is on screen: a chip on the Location row or a Local
764
+ // overrides entry whose file has gone or changed (file-changes.ts).
765
+ void markFileChanges(summaryEl);
766
+ if (!panelAnchorId)
767
+ panelAnchorId = script.id;
768
+ void repaintRelatedVersions({
769
+ // The fallback for a paint whose anchor row can't be read — the
770
+ // very first one included, where the anchor IS this script.
771
+ location: script.fileUrl,
772
+ header: script.header,
773
+ anchorScriptId: panelAnchorId,
774
+ mode: 'installed',
775
+ currentScriptId: script.id,
776
+ }, snapshot);
777
+ tabsView.setTabs(storedTabs(script));
778
+ // Painted AFTER the tabs, not with the rest of the header fields: a
779
+ // location can only become a link once there are tabs to resolve it
780
+ // against, and `setTabs` is what installs them.
781
+ paintScriptMessage(script);
782
+ }
783
+ // ---------- The panel is an index, and it holds still ----------
784
+ //
785
+ // The versions panel's two sections are computed from ONE script: rows
786
+ // at the same `fileUrl` are "This URL", rows with the same
787
+ // `@namespace` + `@name` are "a different URL". Which script that is
788
+ // decides how every row is filed.
789
+ //
790
+ // Selecting another copy from the panel therefore must NOT re-anchor
791
+ // it, or the table the user just clicked in would re-file itself
792
+ // underneath them — the row they picked jumping from the second
793
+ // section to the first, and the rest shuffling with it. The anchor is
794
+ // the script the page was OPENED with, and it stays put for the life
795
+ // of the page; navigating only moves the `is-current` highlight.
796
+ //
797
+ // A full page load on the new copy anchors on that copy instead —
798
+ // which is the pre-navigation behaviour, and still what a
799
+ // middle-click or a link from elsewhere gives.
800
+ // The anchor is an ID, not a frozen copy of the row: the location and
801
+ // identity behind it are re-read from the store on every paint, so a
802
+ // Refresh or a Move of the anchor script re-files the table around what
803
+ // it is NOW. Only *which* script the sections are computed from is
804
+ // sticky.
805
+ //
806
+ // Set on the first installed render and left alone by every
807
+ // navigation. Re-anchored only if the row disappears — see
808
+ // `resolveAnchor`.
809
+ let panelAnchorId = '';
810
+ // What the compare page is told the base is: the copy on screen, or
811
+ // the download this preview is showing — the same side the Content
812
+ // column's `content` was computed from. Nothing when an installed paint
813
+ // has no current script, which is also when there is no verdict to
814
+ // link from.
815
+ function compareBase(args) {
816
+ if (args.mode === 'preview')
817
+ return { preview: previewLocation };
818
+ return args.currentScriptId ? { script: args.currentScriptId } : undefined;
819
+ }
820
+ // The row the next panel paint should put focus on: set when a
821
+ // selection is made from the table, consumed by that paint. Held
822
+ // across the paint rather than restored inline because the paint is
823
+ // fire-and-forget — it finishes after the navigation that started it
824
+ // has already returned.
825
+ let pendingRowFocusId = '';
826
+ // Selecting a copy from the panel opens it in place rather than
827
+ // reloading the page: same table, same order, a different script below
828
+ // it. Two ways in — a viewer link (a row's Location, or its Status,
829
+ // which carries `#Log`), and anywhere else in the row that isn't a
830
+ // control of its own.
831
+ //
832
+ // Only plain left-clicks reach the swap. A modified click or a
833
+ // middle-click means "that copy, but not here", and the row carries
834
+ // that itself (`openRowInNewTab`) — its Location is plain text on this
835
+ // page, so there is no `href` for the browser to do it with. The two
836
+ // links that remain, Status and Collection, still ride their own.
837
+ versionsEl.addEventListener('click', (ev) => {
838
+ // Preview mode has no script of its own to swap out, and its panel's
839
+ // links are how the user leaves the preview.
840
+ if (!scriptId || ev.defaultPrevented || ev.button !== 0)
841
+ return;
842
+ const from = ev.target;
843
+ if (!from || !versionsEl.contains(from))
844
+ return;
845
+ if (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) {
846
+ openRowInNewTab(from, ev);
847
+ return;
848
+ }
849
+ const anchor = from.closest('a');
850
+ if (anchor) {
851
+ const target = viewerLinkTarget(anchor.getAttribute('href'));
852
+ // Not a viewer link — the Collection cell's link to the collections
853
+ // page. Leave it, and don't let the row swallow it either.
854
+ if (!target)
855
+ return;
856
+ // A link to the script already on screen is the current row's Status
857
+ // link, whose `#Log` the tab view handles as an ordinary same-document
858
+ // fragment navigation. Leave it to do that.
859
+ if (target.scriptId === scriptId)
860
+ return;
861
+ ev.preventDefault();
862
+ void navigateToScript(target.scriptId, target.hash);
863
+ return;
864
+ }
865
+ // The rest of the row. `is-navigable` is on exactly the rows that
866
+ // lead somewhere — not the current one, and never in a preview.
867
+ const row = from.closest('tr.is-navigable');
868
+ if (!(row instanceof HTMLElement))
869
+ return;
870
+ const id = row.dataset.scriptId;
871
+ if (!id || id === scriptId)
872
+ return;
873
+ // Every control in the row means something of its own: the enable
874
+ // checkbox and its label, Refresh, the trash can, the offer section's
875
+ // dropdown. A click that lands on one is that control's, not the
876
+ // row's.
877
+ if (from.closest('button, input, select, label'))
878
+ return;
879
+ // A click that ends a drag-selection of the path in the Location cell
880
+ // is the user copying text, not picking a copy.
881
+ if (selectionInPanel())
882
+ return;
883
+ void navigateToScript(id, '');
884
+ });
885
+ // Enter or Space on a focused row, which is the keyboard half of the
886
+ // same gesture. Only when the ROW itself has focus: a control inside it
887
+ // is its own focus target, and Enter there belongs to the control.
888
+ versionsEl.addEventListener('keydown', (ev) => {
889
+ if (!scriptId || ev.defaultPrevented)
890
+ return;
891
+ if (ev.key !== 'Enter' && ev.key !== ' ')
892
+ return;
893
+ const row = ev.target;
894
+ if (!(row instanceof HTMLTableRowElement) || row.tabIndex < 0)
895
+ return;
896
+ const id = row.dataset.scriptId;
897
+ if (!id)
898
+ return;
899
+ // Swallowed on the CURRENT row too, where selecting is a no-op:
900
+ // Space on a focused row scrolls the page, and a tab stop that
901
+ // scrolls when you press the key that activates its neighbours is
902
+ // a worse answer than one that does nothing.
903
+ ev.preventDefault();
904
+ if (id === scriptId)
905
+ return;
906
+ void navigateToScript(id, '');
907
+ });
908
+ // Up / Down anywhere in the panel walk the table a row at a time,
909
+ // landing on the ROW — never on a control inside it, wherever they
910
+ // were pressed from.
911
+ //
912
+ // Without them the table is a long run of tab stops — every row plus
913
+ // every control in it — and reaching the copy three rows down means
914
+ // counting Tabs through the controls of the two rows between. This is
915
+ // the same shape browsers give a listbox, and it is why the CURRENT
916
+ // row is focusable too: a row you cannot stand on is a hole in the
917
+ // walk.
918
+ //
919
+ // Landing on the row rather than on the same column's control in it:
920
+ // the columns don't line up as controls. The Collection cell holds two
921
+ // links (the type bubble and the name), a same-identity row has no
922
+ // Refresh, a disabled collection's checkbox is disabled — so "the
923
+ // corresponding one" is often a guess, and a guess that lands on the
924
+ // bubble when you started on the name is worse than a rule you can
925
+ // predict. Tab still walks the controls within a row.
926
+ //
927
+ // Installed mode only, because that is where the rows are focusable.
928
+ // A preview's rows are not — nothing there selects a copy — so its
929
+ // table has no walk and Tab through the controls is what it gets. The
930
+ // alternative, landing on each row's first control, would be the same
931
+ // coin toss the row rule exists to avoid: the collection <select> in
932
+ // the install-offer section, which the guard below then traps the walk on
933
+ // while every arrow silently re-targets the install.
934
+ versionsEl.addEventListener('keydown', (ev) => {
935
+ if (ev.defaultPrevented)
936
+ return;
937
+ if (ev.key !== 'ArrowUp' && ev.key !== 'ArrowDown')
938
+ return;
939
+ // A modified arrow is somebody else's: the browser's own scroll and
940
+ // caret gestures, none of which mean "next row".
941
+ if (ev.ctrlKey || ev.metaKey || ev.altKey || ev.shiftKey)
942
+ return;
943
+ const from = ev.target;
944
+ if (!(from instanceof HTMLElement))
945
+ return;
946
+ // A closed <select> owns the arrow keys — they change its value, and
947
+ // the offer section's collection picker is one.
948
+ if (from.closest('select'))
949
+ return;
950
+ const row = from.closest('tr');
951
+ if (!row)
952
+ return;
953
+ const rows = walkableRows();
954
+ // `indexOf` below is what keeps a press from inside a row the walk
955
+ // does not step between — the install offer — from joining it.
956
+ const at = rows.indexOf(row);
957
+ if (at < 0)
958
+ return;
959
+ const next = rows[at + (ev.key === 'ArrowDown' ? 1 : -1)];
960
+ if (!next)
961
+ return;
962
+ ev.preventDefault();
963
+ next.focus();
964
+ });
965
+ // Ctrl/Cmd-A anywhere on the page selects the file content — or the
966
+ // Log table when that tab is showing — instead of the whole page
967
+ // (src/extension/select-all.ts). Wherever focus is, Ctrl-A then Ctrl-C copies the
968
+ // file.
969
+ installSelectAll(selectAllTarget);
970
+ // What Ctrl-A selects: the code element (its text is exactly the file —
971
+ // the line-number gutter and the line-flash overlay hold no text), or
972
+ // the Log tab's table, skipping the toolbar buttons. An empty log
973
+ // selects its "No log entries yet" line, and a Log pane that hasn't
974
+ // painted yet selects itself: either way the key never reaches the
975
+ // whole page. Null — the browser's own select-all — only on the
976
+ // not-found page, where the code box is empty and the status box's
977
+ // message is the one thing worth copying.
978
+ function selectAllTarget() {
979
+ if (!codePaneEl.hidden)
980
+ return contentEl.textContent ? contentEl : null;
981
+ if (!logPaneEl.hidden)
982
+ return logPaneEl.querySelector('table, .log-empty') ?? logPaneEl;
983
+ return null;
984
+ }
985
+ // The rows the walk steps between: every body row that can be stood
986
+ // on, in the order they are read. Both sections are separate tables
987
+ // and the walk crosses between them — to the user they are one list
988
+ // under two headings — while the install offer and every preview row,
989
+ // none of which are focusable, are not part of it.
990
+ function walkableRows() {
991
+ const rows = Array.from(versionsEl.querySelectorAll('tbody tr'));
992
+ return rows.filter((r) => r.tabIndex >= 0);
993
+ }
994
+ // Ctrl/Cmd-click and middle-click on a row, which mean "somewhere else,
995
+ // not here". The Location cell is plain text on this page, so there is
996
+ // no `href` left for the browser to do this with — the row carries the
997
+ // gesture instead, and opens the same URL the cell used to link to.
998
+ //
999
+ // Shift (a new window) is deliberately not covered: `window.open`
1000
+ // cannot promise one, and doing nothing is honest where guessing
1001
+ // wrong is not.
1002
+ function openRowInNewTab(from, ev) {
1003
+ if (ev.shiftKey || ev.altKey)
1004
+ return;
1005
+ if (from.closest('a, button, input, select, label'))
1006
+ return;
1007
+ const row = from.closest('tr.is-navigable');
1008
+ if (!(row instanceof HTMLElement))
1009
+ return;
1010
+ const id = row.dataset.scriptId;
1011
+ if (!id)
1012
+ return;
1013
+ // Same guard as the plain click: this is one gesture with two
1014
+ // destinations, so a drag that ended on a selection is neither.
1015
+ if (selectionInPanel())
1016
+ return;
1017
+ ev.preventDefault();
1018
+ window.open(viewerUrl(id), '_blank');
1019
+ }
1020
+ versionsEl.addEventListener('auxclick', (ev) => {
1021
+ if (!scriptId || ev.button !== 1)
1022
+ return;
1023
+ const from = ev.target;
1024
+ if (!from || !versionsEl.contains(from))
1025
+ return;
1026
+ openRowInNewTab(from, ev);
1027
+ });
1028
+ // Is there a live text selection anywhere in the panel?
1029
+ //
1030
+ // Tested against the panel, not the clicked row: a drag from one row
1031
+ // into the next has the `tbody` as its common ancestor, so neither row
1032
+ // contains it and a row-level test would call it no selection at all —
1033
+ // and then navigate, wiping what the user had just selected.
1034
+ function selectionInPanel() {
1035
+ const selection = window.getSelection();
1036
+ if (!selection || selection.isCollapsed || selection.rangeCount === 0)
1037
+ return false;
1038
+ return versionsEl.contains(selection.getRangeAt(0).commonAncestorContainer);
1039
+ }
1040
+ // The `?script=` and fragment of a viewer link, or null for anything
1041
+ // else (a collection link, an external URL).
1042
+ function viewerLinkTarget(href) {
1043
+ if (!href)
1044
+ return null;
1045
+ // `URL.parse` rather than the constructor: a malformed href returns
1046
+ // null here instead of throwing, and an uncaught throw in a click
1047
+ // listener is not free — even though only our own hrefs reach here.
1048
+ const url = URL.parse(href, location.href);
1049
+ if (!url)
1050
+ return null;
1051
+ if (url.origin !== location.origin || url.pathname !== location.pathname)
1052
+ return null;
1053
+ const id = url.searchParams.get('script');
1054
+ return id ? { scriptId: id, hash: url.hash } : null;
1055
+ }
1056
+ // Show another installed copy without a page load: move the URL, then
1057
+ // run the same hydrate the initial load runs.
1058
+ //
1059
+ // `replace` is for the copy on screen being deleted — the entry we are
1060
+ // leaving names a script that no longer exists, so pushing on top of it
1061
+ // would give Back a not-found page. Everything else pushes.
1062
+ async function swapToScript(id, opts = {}) {
1063
+ scriptId = id;
1064
+ const url = `${viewerUrl(id)}${opts.hash ?? ''}`;
1065
+ if (opts.replace)
1066
+ history.replaceState(null, '', url);
1067
+ else
1068
+ history.pushState(null, '', url);
1069
+ await showSelectedScript();
1070
+ }
1071
+ async function navigateToScript(id, hash) {
1072
+ await swapToScript(id, { hash });
1073
+ }
1074
+ // Back / Forward across those entries. A move that changed only the
1075
+ // fragment is left to the tab view's own `hashchange` listener — it
1076
+ // owns which tab and line the fragment names.
1077
+ window.addEventListener('popstate', () => {
1078
+ const id = new URLSearchParams(location.search).get('script');
1079
+ if (!id || id === scriptId)
1080
+ return;
1081
+ scriptId = id;
1082
+ void showSelectedScript();
1083
+ });
1084
+ // Everything the page holds about the script that is leaving, dropped
1085
+ // before the next one is read. Each of these is per-script state that
1086
+ // `loadStored` either sets outright or leaves alone when it has
1087
+ // nothing to set — a stale log ID or stack layout carried across would
1088
+ // be attributed to the new script.
1089
+ async function showSelectedScript() {
1090
+ currentScript = null;
1091
+ currentLogId = '';
1092
+ currentLogLevel = undefined;
1093
+ currentRejection = undefined;
1094
+ stackSegments = [];
1095
+ stackEntryUrl = '';
1096
+ stackBodyLineCount = 0;
1097
+ stackLayoutKey = '';
1098
+ // A log read still in flight belongs to the copy being left.
1099
+ logPaintEpoch++;
1100
+ setMessage('');
1101
+ // NOT `relatedVersionsMessage`: it reports what happened to one ROW
1102
+ // ("Could not refresh <copy>"), and the table it belongs to is
1103
+ // exactly what this navigation leaves standing.
1104
+ tabsView.reseedFromHash();
1105
+ // Where focus goes once the new copy is on screen. The panel is
1106
+ // about to be rebuilt, destroying whatever was clicked or arrowed
1107
+ // to, so something has to claim it or it falls back to <body>.
1108
+ //
1109
+ // Coming from the table, that is the row for the copy now shown —
1110
+ // the same row the user was standing on, which the rebuild turns
1111
+ // into the current row. Keeping their place is the whole point of a
1112
+ // walk through the table, and the row is not silent: it carries
1113
+ // `aria-current` and the copy's location.
1114
+ //
1115
+ // Coming from anywhere else — Back / Forward, mostly — the heading
1116
+ // instead: the title, the details and the code all changed, and a
1117
+ // screen reader has had no document change to announce them.
1118
+ const pending = versionsEl.contains(document.activeElement) ? scriptId ?? '' : '';
1119
+ pendingRowFocusId = pending;
1120
+ await loadStored();
1121
+ // The local, not the field: the paint clears the field, and reading
1122
+ // it back here would send focus to the heading it had just put on
1123
+ // the row.
1124
+ if (!pending)
1125
+ filenameEl.focus();
1126
+ }
1127
+ // Paint the "Other versions" panel for whichever mode is on screen.
1128
+ //
1129
+ // Fire-and-forget from both render paths: the panel is supplementary,
1130
+ // so a slow store read must not delay the page around it, and a failed
1131
+ // one must not blank a page that is otherwise fine. A failure empties
1132
+ // the panel (`:empty` hides it) and logs via `dlog` — no user-facing
1133
+ // message, because "we couldn't tell you about copies you may not
1134
+ // have" is not actionable.
1135
+ //
1136
+ // `location` is the ABSOLUTE URL, not the stored `relativePath`: see
1137
+ // [docs/script-identity.md] for why the match can't use the relative
1138
+ // form.
1139
+ //
1140
+ // `given` is a read the caller just made, used instead of a new one. Not
1141
+ // kept: `lastRelatedPaint` repaints read the store again.
1142
+ async function paintRelatedVersions(args, given) {
1143
+ const generation = ++relatedVersionsGeneration;
1144
+ lastRelatedPaint = args;
1145
+ const snapshot = given ?? await readStoreSnapshot();
1146
+ // A later paint started while this one was reading storage, so this
1147
+ // result is already stale. Drop it rather than let two in-flight
1148
+ // reads race to paint last — the installed viewer repaints on every
1149
+ // `script:*` change, so overlapping paints are routine.
1150
+ if (generation !== relatedVersionsGeneration)
1151
+ return;
1152
+ const anchored = resolveAnchor(args, snapshot.scripts);
1153
+ const related = findRelatedVersions({ ...snapshot, ...anchored });
1154
+ const rows = allRelatedRows(related);
1155
+ lastRelatedIds = new Set(rows.map((r) => r.script.id));
1156
+ lastRelatedRows = rows;
1157
+ // The anchor decides how the TABLE is filed, and nothing else. Two
1158
+ // things on this page are about the copy actually on screen — the
1159
+ // Move picker's targets and the enable toggle's sweep — and after an
1160
+ // in-page switch to a different-URL copy that is not the anchor. Both
1161
+ // read this second lookup, so neither can end up describing the wrong
1162
+ // script's neighbours.
1163
+ //
1164
+ // The extra `findRelatedVersions` is a scan of a snapshot already in
1165
+ // hand, not a second store read, and it is skipped in the ordinary
1166
+ // case where the two agree.
1167
+ const displayed = anchored.location === args.location && anchored.header === args.header
1168
+ ? related
1169
+ : findRelatedVersions({ ...snapshot, location: args.location, header: args.header });
1170
+ // The two inputs the Move picker needs, kept so it can open without
1171
+ // a store read of its own — see `moveTargetContext`.
1172
+ moveTargetContext = { collections: snapshot.collections, sameLocation: displayed.sameLocation };
1173
+ const status = await relatedStatuses(rows);
1174
+ // Same staleness check as after the store read: the session reads
1175
+ // above are two more awaits a newer paint can overtake.
1176
+ if (generation !== relatedVersionsGeneration)
1177
+ return;
1178
+ // Read alongside the panel's other inputs, so every row's trash can
1179
+ // is drawn from the same answer the dialog will act on.
1180
+ copiesSetting = await readCopiesSetting();
1181
+ const actions = {
1182
+ currentScriptId: args.currentScriptId,
1183
+ setEnabled: async (changes, related) => {
1184
+ relatedVersionsMessage = '';
1185
+ // The clicked box has already flipped itself; this catches up the
1186
+ // rest of the batch — the other rows and the page's own toggle —
1187
+ // before the write, so the whole set moves at once.
1188
+ paintEnabledOptimistically(changes);
1189
+ // The checkbox stays grey until the repaint below, so the line
1190
+ // covers both.
1191
+ const timer = startPhaseTimer('versions table enable');
1192
+ let failure;
1193
+ try {
1194
+ await applyEnableChanges(changes, related);
1195
+ timer.mark('write');
1196
+ }
1197
+ catch (err) {
1198
+ failure = err;
1199
+ timer.mark('write failed');
1200
+ relatedVersionsMessage = describeEnableFailure(changes, snapshot.scripts, err);
1201
+ }
1202
+ // Repainted either way, and NOT left to the storage listener.
1203
+ //
1204
+ // On success because each row's handler closes over the row list
1205
+ // as it was when the row was drawn: without a repaint the second
1206
+ // click would compute "the others" from pre-first-click data and
1207
+ // sweep nothing — two enabled copies, the exact state this rule
1208
+ // exists to prevent. A preview has no storage listener at all,
1209
+ // and even on the viewer a batch that turns out to be a no-op
1210
+ // writes nothing and so fires no event.
1211
+ //
1212
+ // On failure because it redraws every box from stored state,
1213
+ // which is a better answer than the renderer's own revert of the
1214
+ // one box that was clicked.
1215
+ await afterPanelAction(args);
1216
+ timer.mark('repaint');
1217
+ timer.note(`${changes.length} change(s)`);
1218
+ timer.done();
1219
+ if (failure)
1220
+ throw new Error(relatedVersionsMessage);
1221
+ },
1222
+ remove: async (id) => {
1223
+ relatedVersionsMessage = '';
1224
+ // A local copy's row deletes its FILE as well, through the same
1225
+ // dialog the trash can beside Local copy… opens — the panel's
1226
+ // button is a second way to the one action, not a second action.
1227
+ // Cancelling there leaves the row alone.
1228
+ const copy = snapshot.scripts.find((s) => s.id === id);
1229
+ const copyCollection = snapshot.collections.find((c) => c.id === copy?.collectionId);
1230
+ if (copy && deletableCopyPath(copy, copyCollection, copiesSetting)) {
1231
+ const result = await openDeleteDialog({ script: copy });
1232
+ if (!result) {
1233
+ await afterPanelAction(args);
1234
+ return;
1235
+ }
1236
+ // Both slots, unconditionally, because either can be where the
1237
+ // user is standing a moment from now: the panel's own line if
1238
+ // this page survives the removal (including a swap to another
1239
+ // copy, whose panel is where the report is still readable), and
1240
+ // the not-found state's detail if it doesn't. Assigned even
1241
+ // when empty, so a previous deletion's report can't outlive it.
1242
+ relatedVersionsMessage = result.dependencyErrors.join(' ');
1243
+ removedHereDetail = result.dependencyErrors;
1244
+ }
1245
+ // Removing the row this page is showing is allowed — it is a copy
1246
+ // like any other, and refusing only where you happen to be
1247
+ // standing would be arbitrary.
1248
+ const isCurrent = id === args.currentScriptId;
1249
+ // Deleting the copy you are standing on moves the page to the
1250
+ // next one in the table rather than stranding it on the
1251
+ // not-found line: the user asked to lose one copy, not to lose
1252
+ // the script. Only for a deletion made HERE — a copy that
1253
+ // vanishes from another tab gets the not-found state, because
1254
+ // silently swapping in some other version would be a page the
1255
+ // user never asked for.
1256
+ //
1257
+ // Both of these are decided BEFORE the removal, because the
1258
+ // removal's own storage event can be delivered while it is still
1259
+ // in flight — anything set afterwards is too late to be seen by
1260
+ // the listener it is meant for.
1261
+ const successor = isCurrent ? await survivingRowAfter(rows, id) : undefined;
1262
+ // Says the not-found state, if one is painted at all, is the
1263
+ // result the user asked for. `render` clears it as soon as a
1264
+ // script is back on screen, so the successor path pays nothing.
1265
+ if (isCurrent)
1266
+ removedHereMessage = REMOVED_HERE;
1267
+ // Holds back the not-found paint on the way to the successor. The
1268
+ // listener sees a `scriptId` that no longer names a stored row
1269
+ // and would tear the whole page down — heading, details, panel,
1270
+ // title — before `swapToScript` builds it back: a full-page flash
1271
+ // that reads as a page load rather than the in-place switch every
1272
+ // other move through this table makes. We already know where the
1273
+ // page is going, so there is nothing for that paint to say.
1274
+ //
1275
+ // Set only where it is owned, and cleared only on the paths that
1276
+ // set it: the other rows' trash cans stay live while this one is
1277
+ // in flight, and a second removal that reset either flag would
1278
+ // re-open the flash for the first.
1279
+ if (isCurrent && successor)
1280
+ swappingAfterRemoval = true;
1281
+ try {
1282
+ await removeScript(id);
1283
+ }
1284
+ catch (err) {
1285
+ if (isCurrent) {
1286
+ removedHereMessage = null;
1287
+ removedHereDetail = [];
1288
+ swappingAfterRemoval = false;
1289
+ }
1290
+ relatedVersionsMessage =
1291
+ `Could not remove ${describeRelatedScript(snapshot.scripts, id)}: ${describeError(err)}`;
1292
+ await afterPanelAction(args);
1293
+ return;
1294
+ }
1295
+ if (isCurrent) {
1296
+ if (successor) {
1297
+ // The fragment is dropped: it named a tab or line in the file
1298
+ // that just went away.
1299
+ try {
1300
+ await swapToScript(successor, { replace: true });
1301
+ }
1302
+ finally {
1303
+ swappingAfterRemoval = false;
1304
+ }
1305
+ return;
1306
+ }
1307
+ // Nothing left to show. Driven from here rather than left to
1308
+ // the storage listener: `removeScript` writes nothing when the
1309
+ // row was already gone, so there may be no event to wait for —
1310
+ // and waiting for one that never comes would leave the page
1311
+ // sitting on a table of rows it no longer has, with the trash
1312
+ // can stuck disabled. `removedHereMessage` makes the not-found state
1313
+ // read as the result the user asked for.
1314
+ await rehydrateAfterStorageChange();
1315
+ return;
1316
+ }
1317
+ await afterPanelAction(args);
1318
+ },
1319
+ update: async (ids) => {
1320
+ relatedVersionsMessage = '';
1321
+ const failures = [];
1322
+ // Sequential, not concurrent: each `updateScript` refetches and
1323
+ // rewrites storage, and the store serializes writes anyway. A
1324
+ // parallel burst would only add contention.
1325
+ //
1326
+ // The try is INSIDE the loop. Wrapping the whole loop would let
1327
+ // one unreachable host abandon every copy after it, silently —
1328
+ // the user asked for all of them.
1329
+ //
1330
+ // The row for the copy on screen goes through the page's own
1331
+ // refresh path (`refreshShownScript`): a local file the check
1332
+ // finds gone drops the row, and that path is what moves the page
1333
+ // to the next copy instead of leaving it on the not-found line.
1334
+ // The other rows' refreshes never touch what this page shows.
1335
+ //
1336
+ // Captured once: a drop moves `scriptId` to the successor, which
1337
+ // is the next row in table order — and so, in a Refresh all, the
1338
+ // next id in this loop. Read live, it would be taken for the
1339
+ // shown copy in its turn. (A preview has no `scriptId`, so the
1340
+ // mode test only says so in words.)
1341
+ const shown = args.mode === 'installed' ? scriptId : null;
1342
+ for (const id of ids) {
1343
+ try {
1344
+ if (id === shown)
1345
+ await refreshShownScript(id);
1346
+ else
1347
+ await updateScript(id, 'manual', 'force');
1348
+ }
1349
+ catch (err) {
1350
+ failures.push(`${describeRelatedScript(snapshot.scripts, id)}: ${describeError(err)}`);
1351
+ }
1352
+ }
1353
+ // Name which copies failed. With several rows in the table,
1354
+ // "Update failed" alone leaves the user no way to tell which one
1355
+ // to look at — see docs/diagnostics.md#wording.
1356
+ if (failures.length === 1) {
1357
+ relatedVersionsMessage = `Could not refresh ${failures[0]}`;
1358
+ }
1359
+ else if (failures.length > 1) {
1360
+ relatedVersionsMessage = `Could not refresh ${failures.length} copies: ${failures.join('; ')}`;
1361
+ }
1362
+ // The page moved — a swap to the successor, the not-found state,
1363
+ // or a row the user picked mid-refresh: the paint it made on the
1364
+ // way is the current one, and `args` describes the copy that is
1365
+ // no longer here. A refreshed row needs nothing more from here —
1366
+ // the storage listener repaints the page from it, as it always
1367
+ // did for this button.
1368
+ if (shown !== null && scriptId !== shown) {
1369
+ if (lastRelatedPaint)
1370
+ await afterPanelAction(lastRelatedPaint);
1371
+ return;
1372
+ }
1373
+ await afterPanelAction(args);
1374
+ },
1375
+ // Omitted — which is what suppresses the offer section — unless the
1376
+ // caller says installing here is a thing the user can actually do.
1377
+ install: args.offerInstall ? (btn, choice) => doInstall(btn, choice) : undefined,
1378
+ };
1379
+ // Which row the keyboard was on, so the rebuild below doesn't cost
1380
+ // the user their place. The rows are the tab stops in this table now,
1381
+ // and the panel repaints for reasons the user didn't ask for — any
1382
+ // `loglevel:` write for a drawn copy — so without this a keyboard
1383
+ // walk through the table can be dumped back to <body> mid-stride.
1384
+ //
1385
+ // A live row holding focus comes first: the user arrowed there
1386
+ // themselves, after the selection, and that is a newer answer than
1387
+ // the selection's.
1388
+ //
1389
+ // Otherwise a pending selection, whose row is by now already gone
1390
+ // from the document — there is nothing left to read the intent off
1391
+ // of. Unless the user has put focus somewhere of their own outside
1392
+ // the panel in the gap: the paint is fire-and-forget, and taking it
1393
+ // back off whatever they reached would be worse than not restoring.
1394
+ const pending = pendingRowFocusId;
1395
+ pendingRowFocusId = '';
1396
+ const focusedRowId = focusedPanelRowId()
1397
+ ?? (pending && focusIsUnclaimed() ? pending : undefined);
1398
+ // The fold toggle is a tab stop too, and the one the panel itself
1399
+ // parks focus on after a fold — so an unasked-for repaint must not
1400
+ // cost it either. A collapsed panel still repaints on any drawn
1401
+ // copy's `loglevel:` write, which is exactly when this would bite.
1402
+ const toggleFocused = document.activeElement !== null
1403
+ && document.activeElement === versionsEl.querySelector('.versions-collapse');
1404
+ // The copy the Compare column compares every other row against.
1405
+ const shownScript = args.mode === 'installed'
1406
+ ? snapshot.scripts.find((s) => s.id === args.currentScriptId)
1407
+ : undefined;
1408
+ const base = compareBase(args);
1409
+ renderRelatedVersions(versionsEl, {
1410
+ ...related,
1411
+ actions,
1412
+ status,
1413
+ content: shownScript ? contentFingerprint(shownScript) : previewContent(),
1414
+ compareHref: base && ((other) => compareUrl(base, other.id)),
1415
+ mode: args.mode,
1416
+ location: args.location,
1417
+ version: args.header ? getValue(args.header, 'version') : undefined,
1418
+ canDeleteFile: (row, collection) => deletableCopyPath(row, collection, copiesSetting) !== null,
1419
+ message: relatedVersionsMessage,
1420
+ });
1421
+ // A pending restore that found no row leaves focus on <body>, since
1422
+ // the heading was skipped for it: the copy was removed while the
1423
+ // paint was reading, or the table collapsed to nothing worth
1424
+ // showing. The heading is the fallback, as everywhere else.
1425
+ if (!restoreRowFocus(focusedRowId)) {
1426
+ if (toggleFocused) {
1427
+ versionsEl.querySelector('.versions-collapse')?.focus();
1428
+ }
1429
+ else if (pending) {
1430
+ claimPendingRowFocus();
1431
+ }
1432
+ }
1433
+ // Now that we know what else is installed, the controls-row toggle
1434
+ // can say what clicking it will do. `render` sets the plain wording
1435
+ // and runs before this — it has no lookup of its own — so this is
1436
+ // where the exclusive case gets named, on the one control whose
1437
+ // effect on other copies is otherwise invisible from where it sits.
1438
+ //
1439
+ // Set on every branch, not only the exclusive one: this runs after
1440
+ // repaints that had no `render` in front of them to restore the
1441
+ // default, so a title left over from a previous state would stick.
1442
+ if (args.currentScriptId && collectionEnabled) {
1443
+ const others = otherActiveCopies(allRelatedRows(displayed), args.currentScriptId);
1444
+ enableLabelEl.title = enableTitle(others.length > 0);
1445
+ }
1446
+ }
1447
+ // The row that should take over when `id` is deleted: the first one
1448
+ // after it in the table that is still in the store, wrapping to the
1449
+ // front. `undefined` when nothing is left, so the caller falls back to
1450
+ // the not-found state.
1451
+ //
1452
+ // Table order, not store order — it is the order the user is looking
1453
+ // at, so the copy that takes over is the one their eye was already
1454
+ // heading towards.
1455
+ //
1456
+ // Each candidate is re-read because `rows` is the table as it was
1457
+ // PAINTED: another tab may have deleted copies since, and swapping to
1458
+ // one of those would answer a deliberate deletion with a not-found
1459
+ // page for a script the user never asked for.
1460
+ async function survivingRowAfter(rows, id) {
1461
+ const at = rows.findIndex((r) => r.script.id === id);
1462
+ if (at < 0)
1463
+ return undefined;
1464
+ for (let step = 1; step < rows.length; step++) {
1465
+ const candidate = rows[(at + step) % rows.length].script.id;
1466
+ if (await getScriptMeta(candidate))
1467
+ return candidate;
1468
+ }
1469
+ return undefined;
1470
+ }
1471
+ // Where this paint's two sections are computed from.
1472
+ //
1473
+ // The anchor row is re-read here rather than remembered, so a Refresh
1474
+ // or Move of the anchor script re-files the table. If it has been
1475
+ // removed outright — its trash can is in this very table — the page
1476
+ // re-anchors on the copy currently on screen: a table filed under a
1477
+ // script that no longer exists would keep describing copies of a
1478
+ // deleted row, and would eventually stop containing the script the
1479
+ // page is showing.
1480
+ function resolveAnchor(args, scripts) {
1481
+ if (!args.anchorScriptId)
1482
+ return { location: args.location, header: args.header };
1483
+ const anchor = scripts.find((s) => s.id === args.anchorScriptId);
1484
+ if (anchor)
1485
+ return { location: anchor.fileUrl, header: anchor.header };
1486
+ if (args.currentScriptId)
1487
+ panelAnchorId = args.currentScriptId;
1488
+ return { location: args.location, header: args.header };
1489
+ }
1490
+ // Has nothing taken focus since the panel started repainting? The
1491
+ // removed row leaves it on <body>, which is the case a pending
1492
+ // restore is for; anything else is the user's own doing, and the
1493
+ // panel is where they were already if they are still in it.
1494
+ function focusIsUnclaimed() {
1495
+ const active = document.activeElement;
1496
+ return active === null || active === document.body || versionsEl.contains(active);
1497
+ }
1498
+ // The script id of the row holding focus, if one is. Current row
1499
+ // included — it is a tab stop like the rest.
1500
+ function focusedPanelRowId() {
1501
+ const active = document.activeElement;
1502
+ if (!(active instanceof HTMLTableRowElement))
1503
+ return undefined;
1504
+ if (!versionsEl.contains(active))
1505
+ return undefined;
1506
+ return active.dataset.scriptId;
1507
+ }
1508
+ // Put focus back on the row for `id` after a rebuild. Silently does
1509
+ // nothing when that row is gone — the copy was removed — because there
1510
+ // is nowhere equivalent to send focus and stealing it to a neighbour
1511
+ // would be worse than letting it fall back.
1512
+ function restoreRowFocus(id) {
1513
+ if (!id)
1514
+ return false;
1515
+ const row = versionsEl.querySelector(`tr[data-script-id="${CSS.escape(id)}"]`);
1516
+ if (!(row instanceof HTMLElement))
1517
+ return false;
1518
+ row.focus();
1519
+ return true;
1520
+ }
1521
+ // Give up on a pending row restore and put focus on the heading, the
1522
+ // same fallback the initial load and a Back use. Shared by every path
1523
+ // that can leave a selection with no row to land on: a failed repaint,
1524
+ // a table that came back without the row, a script that has gone
1525
+ // missing outright.
1526
+ function claimPendingRowFocus() {
1527
+ const claim = focusIsUnclaimed();
1528
+ pendingRowFocusId = '';
1529
+ if (claim)
1530
+ filenameEl.focus();
1531
+ }
1532
+ // The Status cell for every row the panel is about to draw.
1533
+ //
1534
+ // The two inputs live in `chrome.storage.session` — the per-script max
1535
+ // log level and Chrome's registration verdicts — and neither is on the
1536
+ // stored row, so they are read here and handed to the renderer rather
1537
+ // than looked up per cell. One read for the whole table: a session
1538
+ // `get(null)` per row would repeat the same scan once per copy.
1539
+ //
1540
+ // Fails open with an empty map, which renders every cell as an em
1541
+ // dash. The panel is supplementary and its other columns are still
1542
+ // worth showing; a status we could not compute must not blank them.
1543
+ //
1544
+ // Deliberately NOT reusing this page's own `currentLogLevel` /
1545
+ // `currentRejection` for the current row: those track one script, and
1546
+ // a cell that agreed with the page for one row while every neighbour
1547
+ // came from a different read would be the drift this shares a helper
1548
+ // to avoid.
1549
+ async function relatedStatuses(rows) {
1550
+ const out = new Map();
1551
+ let levels;
1552
+ let rejections;
1553
+ try {
1554
+ [levels, rejections] = await Promise.all([getAllMaxLogLevels(), readRejections()]);
1555
+ }
1556
+ catch (err) {
1557
+ dlog(`[SourceMonkey] related-versions status lookup failed: ${describeError(err)}`);
1558
+ return out;
1559
+ }
1560
+ for (const { script } of rows) {
1561
+ out.set(script.id, scriptStatusView(script, levels.get(script.id), rejections[script.id]));
1562
+ }
1563
+ return out;
1564
+ }
1565
+ // Everything that has to catch up after one of the panel's own actions
1566
+ // changed storage.
1567
+ //
1568
+ // On the installed viewer the panel is all there is to redraw: the
1569
+ // `storage.onChanged` listener rehydrates the rest of the page. A
1570
+ // PREVIEW has no such listener — it returns early on `!scriptId` — so
1571
+ // the page around the panel would keep whatever the load-time wiring
1572
+ // left it with. Removing the copy a preview is offering to update, for
1573
+ // instance, would leave the banner claiming it is still installed and
1574
+ // Open / Update pointed at an id that no longer exists, with Install
1575
+ // still hidden because at wiring time there was nothing to add.
1576
+ //
1577
+ // Only for a preview that HAS install wiring: `renderInvalidPreview`
1578
+ // deliberately hides those buttons, and re-running the wiring would put
1579
+ // them back on a file the page has said cannot be installed.
1580
+ async function afterPanelAction(args) {
1581
+ await repaintRelatedVersions(args);
1582
+ if (args.mode !== 'preview' || !args.offerInstall)
1583
+ return;
1584
+ await wireInstallButtons().catch((err) => {
1585
+ // The panel repainted, so the user can still act from the table;
1586
+ // only the header buttons are left as they were.
1587
+ dlog(`[SourceMonkey] preview button re-wiring failed: ${describeError(err)}`);
1588
+ });
1589
+ }
1590
+ // How a failed copy is named in the panel's own message. The collection
1591
+ // display name is what the failing row shows in its Collection cell, so
1592
+ // that is what the user scans for; the location disambiguates two
1593
+ // copies under one collection.
1594
+ function describeRelatedScript(scripts, id) {
1595
+ const script = scripts.find((s) => s.id === id);
1596
+ return script ? displayLocation(script.relativePath) : 'this copy';
1597
+ }
1598
+ // Bumped by every paint so a slower earlier one can tell it lost.
1599
+ let relatedVersionsGeneration = 0;
1600
+ // What the last panel paint was for, so a session-storage change that
1601
+ // moves any drawn copy's Status cell can re-run the same paint. Null
1602
+ // until the first one. Set by previews too, and used there: the
1603
+ // session branch of the listener runs before the `scriptId` gate.
1604
+ let lastRelatedPaint = null;
1605
+ // The ids the panel last drew a Status cell for. Kept so a session
1606
+ // change can be tested against what is actually on screen.
1607
+ let lastRelatedIds = new Set();
1608
+ // The rows themselves, in table order, for a Refresh that drops the
1609
+ // copy on screen and wants the next one (`survivingRowAfter`).
1610
+ let lastRelatedRows = [];
1611
+ // Whether a `storage.session` change carries anything a Status cell
1612
+ // currently on screen reads.
1613
+ //
1614
+ // Matched against the drawn rows, not against the `loglevel:` prefix
1615
+ // alone: a page with a dozen unrelated userscripts throwing during
1616
+ // load would otherwise rebuild this table a dozen times over content
1617
+ // that never changed. The rejection map is one shared row, so any
1618
+ // write to it may name a copy we draw.
1619
+ function panelStatusChanged(changes) {
1620
+ return Object.keys(changes).some((key) => key === REJECTIONS_KEY
1621
+ || (key.startsWith(LOG_LEVEL_KEY_PREFIX)
1622
+ && lastRelatedIds.has(key.slice(LOG_LEVEL_KEY_PREFIX.length))));
1623
+ }
1624
+ // Where local copies live, from the `smDownloadsCopies` setting, as of
1625
+ // the last panel paint. Null before the first copy ever, and until the
1626
+ // first read lands — in both cases no row claims a file it can delete,
1627
+ // which is the safe answer. Re-read per paint rather than watched: the
1628
+ // answer only moves when a copy is made, which repaints the panel
1629
+ // anyway.
1630
+ let copiesSetting = null;
1631
+ const REMOVED_HERE = 'This script has been removed';
1632
+ const REMOVED_FILE_GONE = 'This script has been removed because its file was deleted';
1633
+ // Set while this page is the one making the script it shows go away —
1634
+ // a trash can, or a Refresh that may find the file deleted — to what
1635
+ // the not-found state that follows should say. Without it that state
1636
+ // reads as "the store lost your script" rather than "that worked".
1637
+ // Null when no removal of ours is pending. One value rather than a
1638
+ // flag plus a message, so the two can't disagree: whichever path set
1639
+ // it last is the one whose wording shows.
1640
+ let removedHereMessage = null;
1641
+ // Set while the copy on screen is being removed and a successor is
1642
+ // already picked out: `rehydrateAfterStorageChange` skips the not-found
1643
+ // paint, because the swap that follows is the page the user is getting.
1644
+ // See the panel's `remove` for why it is a flag and not an ordering.
1645
+ let swappingAfterRemoval = false;
1646
+ // What that removal could not finish — today, dependency files a local
1647
+ // copy's deletion left on disk. Carried alongside `removedHereMessage` rather
1648
+ // than written to the message bar after the fact, for the reason
1649
+ // `rehydrateAfterStorageChange` gives. Cleared by `render`, with the
1650
+ // flag itself.
1651
+ let removedHereDetail = [];
1652
+ // The panel's own message, surviving repaints because it is passed
1653
+ // into every render. Cleared when the panel's next action starts, not
1654
+ // on a repaint — a storage change caused by the very refresh that
1655
+ // failed must not erase the report of that failure.
1656
+ let relatedVersionsMessage = '';
1657
+ // A repaint the panel owes to a storage echo, held back while the user
1658
+ // is working a control that a rebuild would destroy.
1659
+ //
1660
+ // The install-offer section's collection dropdown — labelled *Into*,
1661
+ // the panel's copy of the callout's "Install into" — is a native
1662
+ // `<select>`: its
1663
+ // popup is drawn by the browser and dies with the element. A rebuild
1664
+ // while that popup is open closes it, and the mouseup that was going
1665
+ // to pick an entry lands on whatever the rebuild put underneath —
1666
+ // which reads as the dropdown selecting something on its own. Any
1667
+ // session write reaches here (the injector's rejection map, a drawn
1668
+ // copy's log level), so the window is small but real and not the
1669
+ // user's fault.
1670
+ //
1671
+ // Same principle the collections page applies to a toggle mid-write:
1672
+ // a repaint nobody asked for waits for the control being used.
1673
+ // A flag, not the captured args: `lastRelatedPaint` is what every
1674
+ // other repaint reads, and replaying a stale snapshot could let the
1675
+ // flush paint older state over a newer paint that landed meanwhile.
1676
+ let panelPaintDeferred = false;
1677
+ // True while a `<select>` inside the panel holds focus, which is the
1678
+ // state a popup is open in. Focus is the only signal the platform
1679
+ // gives — there is no "is the popup showing" API — and it errs the
1680
+ // safe way: a select merely tabbed to also defers, and the flush below
1681
+ // catches up the moment focus leaves.
1682
+ function panelSelectIsOpen() {
1683
+ const active = document.activeElement;
1684
+ return active instanceof HTMLSelectElement && versionsEl.contains(active);
1685
+ }
1686
+ // Repaint the panel as it was last asked for, unless the offer
1687
+ // section's dropdown is open — then hold the paint back (see
1688
+ // `panelPaintDeferred`). Nothing is lost: the flush repaints from the
1689
+ // latest args once the dropdown is done with. For repaints the panel's
1690
+ // inputs trigger on their own, not for its actions, which paint from
1691
+ // their own args.
1692
+ function repaintPanelFromLastArgs() {
1693
+ if (!lastRelatedPaint)
1694
+ return;
1695
+ if (panelSelectIsOpen()) {
1696
+ panelPaintDeferred = true;
1697
+ }
1698
+ else {
1699
+ panelPaintDeferred = false;
1700
+ void repaintRelatedVersions(lastRelatedPaint);
1701
+ }
1702
+ }
1703
+ // Flush whatever was held back, once the dropdown is done with.
1704
+ //
1705
+ // Two triggers, because focus alone doesn't cover it. Picking an entry
1706
+ // closes the popup but LEAVES the select focused — Chrome fires no
1707
+ // blur — so a `focusout`-only flush would sit pending until the user
1708
+ // happened to click elsewhere, which may be never. A `change` means an
1709
+ // entry was committed, and therefore that the popup is closed, so it
1710
+ // flushes unconditionally.
1711
+ function flushDeferredPanelPaint() {
1712
+ if (!panelPaintDeferred || !lastRelatedPaint)
1713
+ return;
1714
+ panelPaintDeferred = false;
1715
+ void repaintRelatedVersions(lastRelatedPaint);
1716
+ }
1717
+ versionsEl.addEventListener('change', () => flushDeferredPanelPaint());
1718
+ // `focusout` fires BEFORE focus lands on the next element, so the check
1719
+ // is deferred a task — reading `activeElement` synchronously here would
1720
+ // see `body` and repaint out from under a click still travelling to a
1721
+ // sibling control.
1722
+ versionsEl.addEventListener('focusout', () => {
1723
+ setTimeout(() => {
1724
+ if (panelSelectIsOpen())
1725
+ return;
1726
+ flushDeferredPanelPaint();
1727
+ }, 0);
1728
+ });
1729
+ // Re-run the panel after one of its own actions. The installed viewer
1730
+ // also repaints via the `storage.onChanged` listener, but a preview has
1731
+ // no `scriptId` so that listener returns early — this is what keeps the
1732
+ // preview's panel current.
1733
+ //
1734
+ // A panel action is the user asking for this, so it repaints even with
1735
+ // the dropdown focused: by then the click that opened it has been
1736
+ // spent on the button that ran the action.
1737
+ function repaintRelatedVersions(args, snapshot) {
1738
+ return paintRelatedVersions(args, snapshot).catch((err) => {
1739
+ // Emptied, not left as-is: the rows carry live buttons keyed to
1740
+ // script ids, and a failed re-read is exactly when those ids may
1741
+ // have stopped existing. Stale rows that still act would be worse
1742
+ // than none. `:empty` then hides the container.
1743
+ versionsEl.replaceChildren();
1744
+ // A selection was waiting for its row to be drawn, and now no row
1745
+ // will be. Hand focus to the heading rather than let it sit on an
1746
+ // element that has just been removed.
1747
+ if (pendingRowFocusId)
1748
+ claimPendingRowFocus();
1749
+ dlog(`[SourceMonkey] other-versions repaint failed: ${describeError(err)}`);
1750
+ });
1751
+ }
1752
+ // The message bar for an installed script: whichever "here is what's
1753
+ // wrong" line applies, in severity order, so opening a flagged script
1754
+ // always explains why.
1755
+ //
1756
+ // errorMessage — the load failed; the script can't run at all.
1757
+ // rejection — the load was fine but Chrome refused the
1758
+ // registration, so the script never runs either.
1759
+ // Ranked below `errorMessage` only because the two
1760
+ // can't co-occur (an INVALID script is never offered
1761
+ // to Chrome), so the order is a formality.
1762
+ // refreshError — the softer "running on a cached copy, last update
1763
+ // failed" case.
1764
+ //
1765
+ // Split out of `render` so the storage listener can repaint the bar
1766
+ // when a rejection appears or clears without re-rendering the page.
1767
+ function paintScriptMessage(script) {
1768
+ if (script.errorMessage) {
1769
+ setDiagnosticMessage(script.errorMessage, script.errorLocation, 'error');
1770
+ }
1771
+ else if (currentRejection) {
1772
+ // No location: Chrome names the offending value in its own words
1773
+ // (`matches[1]: Invalid port`), and we have no mapping from that
1774
+ // back to a line in the file. See docs/diagnostics.md.
1775
+ setMessage(describeRejection(currentRejection), 'error');
1776
+ }
1777
+ else if (script.refreshError) {
1778
+ // No location: `refreshError` means the fetch failed, so there is
1779
+ // no new content to point into, and the running copy is fine.
1780
+ setMessage(`Refresh failed: ${script.refreshError}`, 'refresh-failed');
1781
+ }
1782
+ else {
1783
+ setMessage('');
1784
+ }
1785
+ }
1786
+ // Tab strip for an installed script: main + one per `@require`, then
1787
+ // the Log tab (always last so the file order stays stable as
1788
+ // `@require`s come and go).
1789
+ function storedTabs(script) {
1790
+ // currentLogId is the log label the injector uses to build
1791
+ // sourceURLs (see script-source-url.ts). Falls back to the bare
1792
+ // basename if the log-id sweep hasn't run yet — same default the
1793
+ // injector uses, so the two stay in lockstep.
1794
+ const label = currentLogId || scriptBasename(script.relativePath);
1795
+ const mainSourceUrl = buildSourceUrl(label);
1796
+ const requireSourceUrls = (script.requires ?? []).map((r) => buildRequireSourceUrl(label, r.url));
1797
+ // Re-derive the injected entry's line layout so the Log tab can undo
1798
+ // it — see `resolveLogRef`. `stackLayout` runs the same shared
1799
+ // `composeSource` the injector runs, given the same URLs and the same
1800
+ // start line, so the segments are the ones the registered entry's
1801
+ // source map was built from.
1802
+ //
1803
+ // Memoised: this concatenates every `@require` body with the script
1804
+ // to find the offsets, then discards the text. Renders fire on any
1805
+ // `script:*` storage mutation, and a script with a couple of MB of
1806
+ // libraries would redo that work behind unrelated events. The
1807
+ // layout only changes when the body does, which is exactly what
1808
+ // `loadedAt` tracks.
1809
+ const layoutKey = `${script.id}@${script.loadedAt}:${label}`;
1810
+ if (layoutKey !== stackLayoutKey) {
1811
+ stackLayoutKey = layoutKey;
1812
+ const layout = stackLayout(script, label);
1813
+ stackSegments = layout.segments;
1814
+ stackEntryUrl = layout.entryUrl;
1815
+ stackBodyLineCount = layout.lineCount;
1816
+ }
1817
+ const tabs = [{
1818
+ kind: 'code',
1819
+ label: scriptBasename(script.relativePath),
1820
+ body: fullSource(script),
1821
+ fileUrl: script.fileUrl,
1822
+ sourceUrl: mainSourceUrl,
1823
+ ...codeTabProvenance({ url: script.fileUrl }),
1824
+ }];
1825
+ for (const [i, r] of (script.requires ?? []).entries()) {
1826
+ tabs.push({
1827
+ kind: 'code',
1828
+ label: requireBasename(r.url, r.ref),
1829
+ body: r.source,
1830
+ // fileUrl is the original fetched URL (for display so the user
1831
+ // sees the real provenance); sourceUrl is the synthetic
1832
+ // sourcemonkey:// URL for this require.
1833
+ //
1834
+ // The injector builds the same URL (`buildRequireSourceUrl` with
1835
+ // the same label) for the require's entry in the combined
1836
+ // entry's source map, so devtools' file and this tab agree.
1837
+ //
1838
+ // Raw stack frames never carry this URL — under Mode A (see
1839
+ // docs/injection-modes.md) V8 tags every frame in the combined
1840
+ // entry with the script's own sourceURL. `stackSegments` below
1841
+ // is what turns such a frame back into a ref at this URL, which
1842
+ // is what lets the Log tab linkify to this tab.
1843
+ fileUrl: r.url,
1844
+ sourceUrl: requireSourceUrls[i],
1845
+ ...codeTabProvenance(r, isScratchUrl(script.fileUrl)),
1846
+ });
1847
+ }
1848
+ tabs.push({ kind: 'log', label: 'Log' });
1849
+ return tabs;
1850
+ }
1851
+ // Tooltip + chip for a code tab, saying where its body came from.
1852
+ //
1853
+ // Every code tab gets the tooltip — the main script's as much as a
1854
+ // `@require`'s. Labels are bare basenames, so "which file is this, and
1855
+ // where did it come from" is a fair question on any of them, and a
1856
+ // script's own URL is exactly as worth having one hover away as a
1857
+ // library's.
1858
+ //
1859
+ // The visible chip is for `localUrl` only: that's the case where the
1860
+ // tab is showing something other than what the declaration names, and a
1861
+ // hover-only disclosure would be no disclosure at all.
1862
+ //
1863
+ // A file sm-dev pushed with a scratch script (`pushed`: one it read from
1864
+ // disk) is a copy taken at the push, which no refresh rereads. Its chip
1865
+ // and tooltip say so, since its `file://` URL alone reads as live.
1866
+ function codeTabProvenance(file, pushed = false) {
1867
+ // Display form on both counts: an SRI hash tail is most of a
1868
+ // tooltip's width and nothing the reader can act on, and a URL
1869
+ // carrying a script's name is a wall of `%20` unless it goes through
1870
+ // `displayLocation` — the same decode every other URL the viewer
1871
+ // shows already gets.
1872
+ const url = displayLocation(stripSriFragment(file.url));
1873
+ if (pushed && (file.localUrl || isFileUrl(file.url))) {
1874
+ const from = file.localUrl ? displayLocation(file.localUrl) : url;
1875
+ return {
1876
+ title: `${file.localUrl ? `${url}\n\n` : ''}Snapshot of ${from}, pushed by sm-dev`,
1877
+ mark: 'snapshot',
1878
+ };
1879
+ }
1880
+ if (!file.localUrl)
1881
+ return { title: url };
1882
+ // Blank line between the two: they are different facts — what the
1883
+ // script declares, and what we read — not a wrapped sentence.
1884
+ //
1885
+ // The stand-in is named by its `file://` URL rather than the bare
1886
+ // path, so both lines are URLs and the pair reads as "asked for this,
1887
+ // got that". The Log's prose lines use the bare path, where a
1888
+ // sentence is what's wanted.
1889
+ return {
1890
+ title: `${url}\n\nLoaded from ${displayLocation(file.localUrl)}`,
1891
+ mark: 'local',
1892
+ };
1893
+ }
1894
+ // Build the Status row content: an anchor whose href is `#Log` so
1895
+ // clicking switches the viewer to the Log tab via the regular
1896
+ // hashchange listener. Labeled with the effective status (INVALID /
1897
+ // REJECTED / OUTDATED / ERROR / WARNING / OK) computed
1898
+ // against the current log level and registration verdict.
1899
+ function buildStatusCell(script) {
1900
+ // The same verdict, wording, and hover text the panel's Status
1901
+ // column below shows for every other copy — a row that explained
1902
+ // itself differently from its own neighbours would read as a
1903
+ // different measurement.
1904
+ //
1905
+ // A `title` is plain text, so an error location is flattened in
1906
+ // rather than linked; the clickable form of the same verdict is in
1907
+ // the message bar this row sits above.
1908
+ const view = scriptStatusView(script, currentLogLevel, currentRejection);
1909
+ const link = document.createElement('a');
1910
+ link.href = '#Log';
1911
+ link.textContent = view.label;
1912
+ link.className = `meta-status status-${view.status}`;
1913
+ if (view.title)
1914
+ link.title = view.title;
1915
+ return link;
1916
+ }
1917
+ // ---------- Preview mode ----------
1918
+ // Three spellings of one location, all needed:
1919
+ //
1920
+ // previewInput — verbatim from the query string. For an http(s)
1921
+ // capture this is the URL the browser was about to
1922
+ // navigate to, so it's what a bypass is keyed on.
1923
+ // previewLocation — `normalizeCollectionInput` then
1924
+ // `canonicalCollectionInput`: a github.com blob URL
1925
+ // rewritten to its raw form, a `file://` URL
1926
+ // collapsed to its bare path, a local path left
1927
+ // alone. Exactly what `addCollection` would store as
1928
+ // `relativePath`, so it's what the
1929
+ // already-installed check compares, and what the
1930
+ // Location row displays.
1931
+ // previewFileUrl — the fetchable form of the above: a bare local
1932
+ // path becomes `file://…`. Used for the fetch, for
1933
+ // resolving `@require` refs, and as link hrefs.
1934
+ let previewLocation = '';
1935
+ let previewFileUrl = '';
1936
+ // The fetched body. Empty until the fetch lands.
1937
+ let previewText = '';
1938
+ // `hashSource` of `previewText`: the download's half of the panel's
1939
+ // Content comparison, in the same terms a stored row's `contentHash`
1940
+ // is. Empty until the text is in hand.
1941
+ let previewContentHash = '';
1942
+ let previewHeader = null;
1943
+ // Per-page cache shared by every lazily-loaded `@require` tab, so
1944
+ // re-opening a tab (or two refs pointing at one URL) fetches once.
1945
+ // It memoizes failures too, so a require that failed once replays its
1946
+ // error for the life of the page rather than silently re-hitting the
1947
+ // host on every tab click; reload to retry.
1948
+ const previewFetchCache = new Map();
1949
+ // Declared `@require` ref → the local file that stood in for it, for
1950
+ // the refs where one did. Filled by the parse check (which resolves
1951
+ // every declared file anyway) and read by `previewTabs` for the tooltip
1952
+ // and the `local` chip.
1953
+ //
1954
+ // Populated after the first tab paint rather than before it: the tabs
1955
+ // are what make the preview appear, and blocking them on a filesystem
1956
+ // probe per require to add a chip would be the wrong trade. The check
1957
+ // re-sets the tab list when it finds one.
1958
+ const previewLocalFiles = new Map();
1959
+ // The parse check's resolved `@require` / `@resource` entries, for the
1960
+ // byte total in the details panel's Size row. Empty until the check
1961
+ // lands, at which point the panel repaints — see `recordPreviewFiles`.
1962
+ let previewFiles;
1963
+ async function loadPreview() {
1964
+ const input = previewInput ?? '';
1965
+ controlsRowEl.hidden = true;
1966
+ actionsEl.hidden = false;
1967
+ cancelBtn.addEventListener('click', leavePage);
1968
+ viewSourceBtn.addEventListener('click', openRawSource);
1969
+ setMessage('Loading…');
1970
+ await githubTokenReady;
1971
+ let text;
1972
+ // Set only for an input we rejected on shape. The catch below is
1973
+ // shared with the real fetch failures, and reporting a malformed
1974
+ // location as one of those would send the reader looking at their
1975
+ // network for a problem that is in the address bar.
1976
+ let badInput = false;
1977
+ try {
1978
+ // Check the shape before canonicalizing: `collectionCanonicalUrl`
1979
+ // turns anything that isn't already http(s)/file:// into a
1980
+ // file:// URL, so an `ftp://…` param would otherwise fail with a
1981
+ // baffling `file:///ftp%3A//…` error instead of being named.
1982
+ if (!isSingleFileLocation(input)) {
1983
+ badInput = true;
1984
+ // A whole sentence, not a fragment: the catch renders it as-is
1985
+ // rather than under the `Could not fetch this script: ` prefix
1986
+ // the other failures there take.
1987
+ throw new Error(`This is not a script location. It ${LOCATION_SHAPES}.`);
1988
+ }
1989
+ // Same canonicalization `addCollection` runs, so what the
1990
+ // already-installed check compares is exactly what installing
1991
+ // would store. A no-op for http(s); a plain POSIX `file://` URL
1992
+ // collapses to its bare path.
1993
+ previewLocation = canonicalCollectionInput(await normalizeCollectionInput(input));
1994
+ previewFileUrl = collectionCanonicalUrl(previewLocation);
1995
+ text = await fetchSingleFileText(previewFileUrl);
1996
+ }
1997
+ catch (err) {
1998
+ // The URL answered with a web page, and we only came here because
1999
+ // the capture guessed it was a script. That guess is wrong, so
2000
+ // undo it: continue to the page the user was heading for, without
2001
+ // asking. A banner offering "Continue to page" for a navigation
2002
+ // the user never asked us to intercept is a dialog about our own
2003
+ // mistake.
2004
+ //
2005
+ // Only for a captured navigation: `?preview=` reached from the
2006
+ // collections page is a question the user asked, and it gets an
2007
+ // answer instead.
2008
+ if (previewWasCaptured && err instanceof MarkupResponseError &&
2009
+ continueToPage('This location is a web page, not a script. Continuing to it.')) {
2010
+ return;
2011
+ }
2012
+ // Keep the heading and the action row: the location is still worth
2013
+ // showing, and "View raw source" is a real retry — that navigation
2014
+ // carries cookies the extension's fetch doesn't.
2015
+ const label = scriptLabel(null, previewLocation || input);
2016
+ renderHeading(PREVIEW_HEADING, label, label, {
2017
+ matching: { matches: [] },
2018
+ header: null,
2019
+ fileUrl: previewFileUrl || input,
2020
+ });
2021
+ setPageTitle(PREVIEW_TITLE, label);
2022
+ setMessage(badInput ? describeError(err) : `Could not fetch this script: ${describeError(err)}`, 'error');
2023
+ // The likeliest reason a local file wouldn't read. `previewFileUrl`
2024
+ // is still '' if normalizeCollectionInput is what threw, which is
2025
+ // correctly not a file:// URL — we never got as far as a read.
2026
+ showFileAccessBannerIfBlocked(previewFileUrl);
2027
+ summaryEl.replaceChildren();
2028
+ tabsView.clear();
2029
+ hideInstallCallout();
2030
+ // A fetch that failed for a location we already hold a copy of is
2031
+ // exactly when the user wants to reach the copy that works. Only
2032
+ // meaningful once we know the normalized location — if
2033
+ // normalizeCollectionInput is what threw, there's nothing to match
2034
+ // on.
2035
+ if (previewLocation)
2036
+ void offerOpenExisting();
2037
+ // A markup response that we did NOT navigate away from (the user
2038
+ // asked for this preview) still deserves the way out the invalid
2039
+ // path offers — before this guard existed the page rendered as
2040
+ // "invalid" and carried this button. There is no body to show
2041
+ // alongside it, so View raw source stays as the way to see what
2042
+ // actually came back.
2043
+ if (err instanceof MarkupResponseError)
2044
+ offerContinueButton();
2045
+ return;
2046
+ }
2047
+ setMessage('');
2048
+ previewText = text;
2049
+ previewContentHash = await hashSource(text);
2050
+ renderPreview(text);
2051
+ }
2052
+ // What the panel compares its rows against on a preview: the download
2053
+ // on screen, in `contentFingerprint` terms — which also answers
2054
+ // undefined until the parse check has fetched the `@require`s the
2055
+ // header declares, so no row says Same only to flip once they land.
2056
+ function previewContent() {
2057
+ if (!previewContentHash)
2058
+ return undefined;
2059
+ return contentFingerprint({
2060
+ contentHash: previewContentHash,
2061
+ header: previewHeader,
2062
+ requires: previewFiles?.requires ?? [],
2063
+ });
2064
+ }
2065
+ function renderPreview(text) {
2066
+ previewHeader = parseHeader(text);
2067
+ // Identity this file's own diagnostics name it by — the same one
2068
+ // `checkPreviewSource` passes as `relativePath`, so a header problem
2069
+ // and a syntax error both resolve to the main tab.
2070
+ mainDiagFile = previewLocation;
2071
+ const label = scriptLabel(previewHeader, previewLocation);
2072
+ renderHeading(PREVIEW_HEADING, label, displayLocation(previewLocation), {
2073
+ matching: distillMatching(previewHeader),
2074
+ header: previewHeader,
2075
+ fileUrl: previewFileUrl,
2076
+ });
2077
+ setPageTitle(PREVIEW_TITLE, label);
2078
+ // Same split as the installed viewer's Location row: the bare fs path
2079
+ // reads better for a local file, but only the file:// URL is a usable
2080
+ // href, and the copy button hands over the raw form either way.
2081
+ currentFilenamePath = previewLocation;
2082
+ renderPreviewDetails();
2083
+ tabsView.setTabs(previewTabs(text, previewHeader));
2084
+ // Previews are shareable URLs too, so `#<file>:<line>:<col>` has to
2085
+ // land the same way it does for an installed script.
2086
+ tabsView.applyHash(true);
2087
+ // Same validation the store runs when a script is added, so what the
2088
+ // preview says about a file matches what installing it would produce.
2089
+ const error = previewValidationError(text, previewHeader);
2090
+ // Painted either way — a file that won't install is exactly when the
2091
+ // copies you already have are worth seeing — but an invalid one gets
2092
+ // no Install offer, or the panel would contradict the banner above it
2093
+ // and the Install button the invalid path just hid.
2094
+ void repaintRelatedVersions({
2095
+ location: previewLocation,
2096
+ header: previewHeader,
2097
+ mode: 'preview',
2098
+ offerInstall: !error,
2099
+ });
2100
+ if (error) {
2101
+ renderInvalidPreview(error);
2102
+ return;
2103
+ }
2104
+ void wireInstallButtons()
2105
+ .catch((err) => {
2106
+ setMessage(`Could not check whether this is already installed: ${describeError(err)}`, 'error');
2107
+ })
2108
+ // After, not alongside: `wireInstallButtons` renders the banner
2109
+ // with `replaceChildren`, so a warning appended while it was still
2110
+ // in flight would be wiped out.
2111
+ .then(() => runPreviewParseCheck(text, previewHeader))
2112
+ .catch((err) => {
2113
+ // Fails open like every other leg of the check — the preview is
2114
+ // still usable, and the install-time check remains the backstop.
2115
+ dlog(`[SourceMonkey] preview parse check skipped: ${describeError(err)}`);
2116
+ });
2117
+ }
2118
+ // Compile what installing this file would register, and say so before
2119
+ // the user commits. Without it the first report of a syntax error was
2120
+ // an `INVALID` row on the page you land on AFTER clicking Install.
2121
+ //
2122
+ // Warns rather than blocking. A missing `@match` hides Install because
2123
+ // the file isn't an installable userscript at all; a syntax error is a
2124
+ // property of code the user may well intend to install and fix (or
2125
+ // install because they trust it more than our verdict). So the button
2126
+ // stays live and the banner says what will happen.
2127
+ //
2128
+ // This is where the preview stops being lazy about `@require`s. Their
2129
+ // bodies are half the program a syntax error can live in — the case
2130
+ // that prompted this was a broken library, not a broken body — so the
2131
+ // check fetches every declared one up front, and the tabs get them
2132
+ // from the shared cache for free.
2133
+ async function runPreviewParseCheck(text, header) {
2134
+ try {
2135
+ await checkPreviewSource(text, header);
2136
+ }
2137
+ finally {
2138
+ // "The check is over", whatever it concluded. Tests need a signal
2139
+ // they can wait on — especially the ones asserting a warning is
2140
+ // ABSENT, which otherwise race the fetches and the compile and
2141
+ // pass for the wrong reason.
2142
+ bannerSlotEl.dataset.parseCheck = 'done';
2143
+ }
2144
+ }
2145
+ // `header` is passed in rather than read from module scope, even
2146
+ // though it currently holds the same value: the text being compiled
2147
+ // and the header describing its `@require`s have to be the same
2148
+ // render's, and taking one from an argument and the other from a
2149
+ // mutable module variable is how those quietly come apart.
2150
+ async function checkPreviewSource(text, header) {
2151
+ const now = Date.now();
2152
+ const script = await buildScriptFromText({
2153
+ // Placeholder identity: nothing is stored, and the preview's own
2154
+ // location is what `addCollection` would record for a real install.
2155
+ id: 'preview',
2156
+ collectionId: 'preview',
2157
+ relativePath: previewLocation,
2158
+ fileUrl: previewFileUrl,
2159
+ text,
2160
+ now,
2161
+ });
2162
+ // A header problem is already on screen (`renderInvalidPreview`), and
2163
+ // an unparsable header means there's no body to judge.
2164
+ if (script.status !== 'ok')
2165
+ return;
2166
+ // The same call the store makes, not a hand-rolled loop beside it.
2167
+ // Fetching each declared `@require` individually looks equivalent and
2168
+ // isn't: `attachFiles` dedups by canonical URL (a library declared
2169
+ // twice is loaded once), and it covers `@resource`, whose bytes are
2170
+ // baked into the checked text and whose fetch failures land a real
2171
+ // install as `invalid`. A loop that skipped both would compose a
2172
+ // program no install ever produces — and duplicate library bodies
2173
+ // read as `already been declared`, i.e. a syntax error reported
2174
+ // against a perfectly healthy script.
2175
+ const files = await attachFiles({
2176
+ scriptFileUrl: previewFileUrl,
2177
+ header,
2178
+ priorRequires: new Map(),
2179
+ priorResources: new Map(),
2180
+ recheck: 'all',
2181
+ fetchCache: previewFetchCache,
2182
+ now,
2183
+ // A preview has no collection row yet, so there's no per-collection
2184
+ // opt-out to consult — a file the user is looking at on their own
2185
+ // disk gets the same-folder rule. This is also the case the rule
2186
+ // matters most for: the published URL a script `@require`s may not
2187
+ // exist yet while it's being written.
2188
+ localRoots: previewLocalRoots(),
2189
+ });
2190
+ // Only on success. A failed `attachFiles` returns EMPTY lists rather
2191
+ // than partial ones, so recording it would strip the size and `local`
2192
+ // annotations off every row in the details panel — including the rows
2193
+ // for files that loaded fine — because one dependency was broken.
2194
+ if (!files.errorMessage)
2195
+ recordPreviewFiles(files);
2196
+ if (files.errorMessage) {
2197
+ // Deliberately softer than the syntax-error wording below. A dep
2198
+ // that won't load is usually a flaky host, and the store goes out
2199
+ // of its way not to disable a working script over one — an Update
2200
+ // keeps the prior copy running and records a refresh failure. So
2201
+ // say what we couldn't do, not what will happen.
2202
+ appendPreviewWarning({
2203
+ message: `This script's code could not be checked: ${files.errorMessage}`,
2204
+ // The file that wouldn't load has no tab and no content, so the
2205
+ // location is the `@require` / `@resource` line that named it.
2206
+ location: locationIn(files.errorLine),
2207
+ });
2208
+ return;
2209
+ }
2210
+ if (files.requires.length > 0)
2211
+ script.requires = files.requires;
2212
+ if (files.resources.length > 0)
2213
+ script.resources = files.resources;
2214
+ const verdict = await checkScriptParses(script);
2215
+ // `unknown` and `null` are the machinery saying it couldn't decide.
2216
+ // Only V8 answering SyntaxError may accuse a script — see
2217
+ // [docs/parse-check.md].
2218
+ if (verdict?.status !== 'syntax-error')
2219
+ return;
2220
+ appendPreviewWarning({
2221
+ message: `This script's code has a syntax error, so none of it would run: ` +
2222
+ `${verdict.message ?? 'unknown'}`,
2223
+ // Points into the `@require` tab when the error is in a library —
2224
+ // the preview builds a tab per declared `@require`, so those are
2225
+ // reachable even though nothing is installed yet.
2226
+ //
2227
+ // Validated here because this is the one path where numbers from
2228
+ // the sandbox reach the DOM without passing through the store or
2229
+ // the log: `isParseVerdict` checks only `status`, and a malformed
2230
+ // probe would otherwise render `line NaN` and scroll to it.
2231
+ location: validLocation(verdict.location),
2232
+ trailing: `. ${syntaxErrorOutcome()}`,
2233
+ });
2234
+ }
2235
+ // What actually happens if the user goes ahead — which is not one
2236
+ // sentence, because `rebuildCollection` (script-store.ts) deliberately
2237
+ // treats an http(s) refresh that doesn't parse as a bad *response*
2238
+ // rather than a bad script: a 200 login page served in place of the
2239
+ // source must not take down the copy that works. So an Update over
2240
+ // http keeps the installed body running and flags the refresh, and
2241
+ // promising INVALID there would simply be wrong.
2242
+ //
2243
+ // A first install has no prior copy to preserve, and a file:// read
2244
+ // returns the file's real bytes, so both of those really do land
2245
+ // INVALID.
2246
+ function syntaxErrorOutcome() {
2247
+ // `previewInstalledStatus` is the question `wireInstallButtons`
2248
+ // already answered — is there a row at this location? — rather than
2249
+ // which button it put on screen. Those came apart once the header
2250
+ // buttons started standing down in favour of the table: a copy can
2251
+ // exist here while Update is deliberately hidden, and calling that
2252
+ // an install would be wrong. It degrades the same way the old proxy
2253
+ // did: null until the wiring runs, so an unwired preview describes
2254
+ // an install, which is what it offers.
2255
+ if (previewInstalledStatus === null)
2256
+ return 'Installing it will store it as INVALID.';
2257
+ // Preservation is gated on the installed row being `ok`, not merely
2258
+ // on it existing: `rebuildCollection` keeps a prior copy running
2259
+ // because that copy WORKS. Updating a row that is already `invalid`
2260
+ // has nothing worth keeping, so the bad parse is stored like any
2261
+ // other.
2262
+ if (isHttpUrl(previewLocation) && previewInstalledStatus === 'ok') {
2263
+ return 'Updating it will leave the installed copy running and mark the refresh as failed.';
2264
+ }
2265
+ return 'Updating it will store it as INVALID.';
2266
+ }
2267
+ // Status of the row this preview would update, or null when nothing is
2268
+ // installed for this location. Recorded by `wireInstallButtons` so the
2269
+ // warning above can describe the outcome the store will actually
2270
+ // produce, which depends on the prior row and not just on the URL.
2271
+ let previewInstalledStatus = null;
2272
+ // Warnings the parse check has reported, so `renderBanner` can put them
2273
+ // back after it replaces the slot. Preview-only; empty for an
2274
+ // installed script.
2275
+ //
2276
+ // Held structurally rather than as rendered text: a replaced banner
2277
+ // rebuilds each warning's DOM from scratch, and a location that had
2278
+ // been flattened to a string on the way in could never come back as a
2279
+ // link.
2280
+ const previewWarnings = [];
2281
+ // Add a warning box below whatever the banner slot already says, rather
2282
+ // than replacing it — the install/installed banner is still true, and
2283
+ // the two facts belong on screen together.
2284
+ function appendPreviewWarning(warning) {
2285
+ previewWarnings.push(warning);
2286
+ bannerSlotEl.append(makeBanner(warning));
2287
+ }
2288
+ // The reason this file can't be installed, or null. Deliberately the
2289
+ // same pair of checks `buildScriptFromText` runs — a preview that
2290
+ // offers Install for something that would land as an `invalid` row
2291
+ // would be lying.
2292
+ function previewValidationError(text, header) {
2293
+ const split = splitHeaderBody(text);
2294
+ if (split.error) {
2295
+ return {
2296
+ message: cannotInstall(split.error),
2297
+ // An unterminated block has one useful place to point: the line
2298
+ // that opened it. A file with no block at all has none, so the
2299
+ // location is the file itself — clicking still gets the reader to
2300
+ // the source, which is where they have to start.
2301
+ location: locationIn(split.headerStartLine),
2302
+ };
2303
+ }
2304
+ const problem = validateHeader(header);
2305
+ if (!problem)
2306
+ return null;
2307
+ return {
2308
+ message: cannotInstall(problem.message),
2309
+ location: locationIn(problem.line),
2310
+ };
2311
+ }
2312
+ // The store's own wording for the problem, in a sentence about what it
2313
+ // means here. The preview used to keep its own phrasing for the two
2314
+ // metadata-block failures, which meant one problem had two texts and
2315
+ // only one of them ever got improved.
2316
+ //
2317
+ // "This file", not "this script": the most common problem here is that
2318
+ // the thing has no metadata block at all, and a prefix calling it a
2319
+ // script would contradict the message saying it isn't one. Every other
2320
+ // problem is about a file too.
2321
+ //
2322
+ // No trailing period: `appendDiagnostic` renders this as
2323
+ // `<message> (in <where>)`, so one here would sit before the
2324
+ // parenthetical rather than after it.
2325
+ function cannotInstall(problem) {
2326
+ return `This file cannot be installed: ${problem}`;
2327
+ }
2328
+ // A location in the previewed script's own source. `previewLocation` is
2329
+ // the identity `checkPreviewSource` gives it when composing the checked
2330
+ // text, so a header diagnostic and a syntax error name the same file.
2331
+ function locationIn(line) {
2332
+ return line === undefined
2333
+ ? { file: previewLocation }
2334
+ : { file: previewLocation, line };
2335
+ }
2336
+ function renderInvalidPreview(error) {
2337
+ renderBanner(error);
2338
+ hideInstallCallout();
2339
+ // Nothing to install or update, but if we already hold a copy of
2340
+ // this location the user still wants a way to reach it — a broken
2341
+ // fetch of a script you have installed is exactly when you'd want to
2342
+ // go look at the copy that works.
2343
+ void offerOpenExisting().catch(() => {
2344
+ // Only costs the user the Open button; the banner already says
2345
+ // what's wrong with the file.
2346
+ });
2347
+ // Most likely this location isn't a script at all — a local
2348
+ // `.user.js` the user opened to read, or a file that never had a
2349
+ // metadata block. The capture can't tell in advance; it runs before
2350
+ // any response exists. So give the user the way through that landing
2351
+ // on nothing else offers: go to the location itself, with the
2352
+ // capture stood down.
2353
+ //
2354
+ // The one shape that does NOT reach here is a markup response: that
2355
+ // continues on its own without asking (see `loadPreview`).
2356
+ offerContinueButton();
2357
+ }
2358
+ // Show the escape hatch: leave the preview for the location itself.
2359
+ // Shared by the invalid-file path and the markup one, so the two can't
2360
+ // drift on the wording or on the bypass.
2361
+ function offerContinueButton() {
2362
+ const target = continueTarget();
2363
+ if (!target)
2364
+ return;
2365
+ // "page" is wrong for a local file, and this button's whole job is
2366
+ // to tell the user where they'll end up.
2367
+ if (isFileUrl(target))
2368
+ continueBtn.textContent = 'Continue to file';
2369
+ continueBtn.hidden = false;
2370
+ continueBtn.addEventListener('click', () => { continueToPage(); });
2371
+ }
2372
+ // The navigable spelling of the previewed location: the verbatim input
2373
+ // for http(s), the `file://` form for a local file (a bare `/path`
2374
+ // would resolve against the extension's own origin). Empty when
2375
+ // neither is available — the input was rejected on shape, so there is
2376
+ // nowhere to send anyone.
2377
+ function continueTarget() {
2378
+ return isHttpUrl(previewInput ?? '')
2379
+ ? previewInput ?? ''
2380
+ : isFileUrl(previewFileUrl) ? previewFileUrl : '';
2381
+ }
2382
+ // Leave the preview for the location itself, in THIS tab — the user is
2383
+ // going there instead of installing, not opening a second tab.
2384
+ //
2385
+ // Returns false when there is no navigable target, so a caller that
2386
+ // was counting on leaving can fall back to rendering the problem. A
2387
+ // `true` only promises the navigation was *started*: the bypass write
2388
+ // is async, and if it fails `withBypass` reports that into the message
2389
+ // bar of a page the caller has already stopped rendering. Storage
2390
+ // writes at this size don't realistically fail, and awaiting one on
2391
+ // the way out would cost every reader the delay.
2392
+ // Both callers stand the capture down first (`withBypass`), or the
2393
+ // navigation would be pulled straight back into this preview.
2394
+ //
2395
+ // `note` is for the leg that leaves on its own: an extension page that
2396
+ // blanks and jumps elsewhere should say why, even though the line is
2397
+ // only ever seen if the navigation is slow. The button needs none —
2398
+ // the banner above it already said what this file is, and the user
2399
+ // clicked knowing where they were going.
2400
+ function continueToPage(note) {
2401
+ const target = continueTarget();
2402
+ if (!target)
2403
+ return false;
2404
+ if (note)
2405
+ setMessage(note);
2406
+ withBypass(target, (url) => { location.href = url; });
2407
+ return true;
2408
+ }
2409
+ // Tab strip for a preview: the fetched text, then one tab per declared
2410
+ // `@require`. The bodies are thunks, but they no longer decide WHEN a
2411
+ // dependency is fetched: `runPreviewParseCheck` pulls every declared
2412
+ // file as soon as the preview renders, because the check judges the
2413
+ // composed program. Both share `previewFetchCache`, so opening a tab
2414
+ // is a cache hit. No Log tab: nothing has run.
2415
+ function previewTabs(text, header) {
2416
+ const fileUrl = previewFileUrl;
2417
+ const tabs = [{
2418
+ kind: 'code',
2419
+ label: scriptBasename(previewLocation),
2420
+ body: text,
2421
+ fileUrl,
2422
+ // No stack frames without a run, so nothing to linkify against.
2423
+ sourceUrl: '',
2424
+ ...codeTabProvenance({ url: fileUrl }),
2425
+ }];
2426
+ for (const ref of distillRequires(header)) {
2427
+ const resolved = resolveScriptResource(ref, fileUrl);
2428
+ // An unresolvable ref still gets a tab: opening it surfaces the
2429
+ // same rejection message installing would produce.
2430
+ const url = resolved && resolved.kind !== 'data' ? resolved.url : '';
2431
+ tabs.push({
2432
+ kind: 'code',
2433
+ label: url ? requireBasename(url, ref) : ref,
2434
+ body: () => loadPreviewRequire(ref, fileUrl),
2435
+ fileUrl: url || fileUrl,
2436
+ sourceUrl: '',
2437
+ // `url` is '' for a ref we couldn't resolve; that tab's job is to
2438
+ // report the rejection when opened, and a tooltip repeating the
2439
+ // label would add nothing.
2440
+ ...(url
2441
+ ? codeTabProvenance({ url, localUrl: previewLocalFiles.get(ref) })
2442
+ : {}),
2443
+ });
2444
+ }
2445
+ return tabs;
2446
+ }
2447
+ // The preview's details panel. Its own function because it is painted
2448
+ // twice: once as soon as the body renders, and again when the parse
2449
+ // check comes back with the resolved `@require` / `@resource` entries —
2450
+ // the Size row totals their bytes, which aren't known on the first
2451
+ // pass.
2452
+ function renderPreviewDetails() {
2453
+ renderDetails(summaryEl, {
2454
+ header: previewHeader,
2455
+ mode: 'preview',
2456
+ files: previewFiles,
2457
+ scriptUrl: previewFileUrl,
2458
+ location: {
2459
+ text: displayLocation(previewLocation),
2460
+ href: previewFileUrl,
2461
+ trailing: filenameCopyBtn,
2462
+ // Navigating this href for real would be captured straight back
2463
+ // into a second preview of the same script. Route it through the
2464
+ // same path the View raw source button uses.
2465
+ onActivate: openRawSource,
2466
+ },
2467
+ size: formatBytes(new TextEncoder().encode(previewText).length),
2468
+ });
2469
+ }
2470
+ // Local search roots for the previewed file. Not gated on a stored
2471
+ // collection row: a preview is a file the user pointed at directly, and
2472
+ // `localSearchRoots` still refuses anything that isn't `file://`.
2473
+ function previewLocalRoots() {
2474
+ return localSearchRoots(previewFileUrl, { enabled: true });
2475
+ }
2476
+ // Fold the parse check's resolved files into the preview's state and
2477
+ // repaint what depends on them: the details panel's Size total and the
2478
+ // tab strip's tooltips and chips.
2479
+ //
2480
+ // Repainting the strip is safe at any time — `setTabs` keeps the
2481
+ // selected index, so a user who has already opened a require tab stays
2482
+ // on it — but it is still gated on an actual change, because the check
2483
+ // runs again on every re-render.
2484
+ function recordPreviewFiles(files) {
2485
+ // Rebuilt from this pass, never merged into. Merging would make the
2486
+ // map add-only: a ref whose stand-in was deleted between checks would
2487
+ // keep its `local` chip and a "Loaded from …" tooltip naming a file
2488
+ // that no longer exists — precisely the silent-wrong-provenance this
2489
+ // feature exists to prevent.
2490
+ const next = new Map();
2491
+ for (const r of files.requires) {
2492
+ if (r.localUrl)
2493
+ next.set(r.ref, r.localUrl);
2494
+ }
2495
+ const changed = next.size !== previewLocalFiles.size ||
2496
+ [...next].some(([ref, url]) => previewLocalFiles.get(ref) !== url);
2497
+ previewLocalFiles.clear();
2498
+ for (const [ref, url] of next)
2499
+ previewLocalFiles.set(ref, url);
2500
+ // The panel's Compare column compares against these too — see
2501
+ // `previewContent`. Repainted only when its answer moved: the first
2502
+ // time the requires land (the column was showing dashes for them),
2503
+ // or a re-check that fetched a library with new content.
2504
+ const contentBefore = previewContent();
2505
+ previewFiles = { requires: files.requires, resources: files.resources };
2506
+ renderPreviewDetails();
2507
+ if (previewContent() !== contentBefore)
2508
+ repaintPanelFromLastArgs();
2509
+ if (changed)
2510
+ tabsView.setTabs(previewTabs(previewText, previewHeader));
2511
+ }
2512
+ async function loadPreviewRequire(ref, fileUrl) {
2513
+ const entry = await fetchRequireForPreview(ref, fileUrl, previewFetchCache, Date.now(), previewLocalRoots());
2514
+ return entry.source;
2515
+ }
2516
+ // Look for existing rows at this location and pick the button set.
2517
+ //
2518
+ // What the header offers depends only on whether ANY copy exists:
2519
+ //
2520
+ // - none anywhere → the install callout: "Install into [collection]",
2521
+ // with the built-in default for this script's scheme pre-selected,
2522
+ // so the common case is one click;
2523
+ // - any copy, here or from a mirror → the "already installed" banner,
2524
+ // with the panel in the same box as the only place to act. Every
2525
+ // action is in there — Open (the location link) and Update on each
2526
+ // copy, Install with the same dropdown in the panel's own
2527
+ // "Install another copy of this script" section — so header buttons
2528
+ // would only leave the user guessing which copy they meant.
2529
+ //
2530
+ // The one exception is a location with no single-file spelling (a
2531
+ // `foo.js`, a manifest URL): the panel has no offer section to carry
2532
+ // Install, so the callout stays — without a dropdown — as long as
2533
+ // nothing is installed at this location. `addCollection` judges those.
2534
+ async function wireInstallButtons() {
2535
+ // Re-runnable: `afterPanelAction` calls this again when a panel
2536
+ // action changed what is installed. Every control it owns is reset
2537
+ // first, or a copy that has just been removed would leave Open on
2538
+ // screen pointed at an id that no longer exists.
2539
+ openBtn.hidden = true;
2540
+ previewInstalledStatus = null;
2541
+ const related = await findRelatedHere();
2542
+ const existing = related.sameLocation[0]?.script;
2543
+ // Recorded before any early return: the parse-check warning describes
2544
+ // what installing WOULD do, and that depends on the row that exists
2545
+ // here, not on which surface the user will click.
2546
+ if (existing)
2547
+ previewInstalledStatus = existing.status;
2548
+ // "Not installed" means no copy ANYWHERE, not just none at this URL:
2549
+ // a same-`@name` copy from a mirror is still this script, installed.
2550
+ if (!existing && !related.sameIdentity.length) {
2551
+ clearBanner();
2552
+ // Nothing of this script anywhere, so Install is the only thing to
2553
+ // do here and reads as the default.
2554
+ showInstallCallout(related.installOptions, true);
2555
+ return;
2556
+ }
2557
+ renderInstalledBanner();
2558
+ // Copies are listed below, so Install is an alternative to updating
2559
+ // one of them rather than the recommended move — see the table's own
2560
+ // Update buttons, which take the emphasis instead.
2561
+ if (!related.installOptions && !existing)
2562
+ showInstallCallout(undefined, false);
2563
+ else
2564
+ hideInstallCallout();
2565
+ }
2566
+ // The callout, with the dropdown when the location can be installed as
2567
+ // a single script. Every entry, "New collection…" included, only
2568
+ // selects — Install is what acts, and for "New collection…" it opens
2569
+ // the create dialog first and installs into what that makes.
2570
+ //
2571
+ // `recommended` is what makes Install bold. It only is when this file
2572
+ // is new to SourceMonkey: with copies already listed in the table
2573
+ // below, updating one of them is the likelier thing to want, and a
2574
+ // bold Install would be pointing at the wrong default.
2575
+ function showInstallCallout(options, recommended) {
2576
+ installTargetSlot.replaceChildren();
2577
+ installTargetLabel.hidden = !options;
2578
+ installCalloutEl.hidden = false;
2579
+ installBtn.disabled = false;
2580
+ installBtn.classList.toggle('primary', recommended);
2581
+ if (!options) {
2582
+ installBtn.onclick = () => void doInstall(installBtn, undefined);
2583
+ return;
2584
+ }
2585
+ const select = buildInstallSelect(options, { onChange: () => sync() });
2586
+ select.id = 'install-target';
2587
+ // Its visible label is only the connector "into" — the phrase the
2588
+ // row reads as is spread across the button, the word and the
2589
+ // dropdown, so the dropdown carries the whole of it as its name.
2590
+ select.setAttribute('aria-label', 'Install into');
2591
+ // The pre-selected default can be one that already holds this
2592
+ // script; Install then waits for a deliberate pick.
2593
+ const sync = () => {
2594
+ installBtn.disabled = installChoiceBlocked(select, options);
2595
+ };
2596
+ installTargetSlot.appendChild(select);
2597
+ sync();
2598
+ installBtn.onclick = () => {
2599
+ void doInstall(installBtn, selectedInstallChoice(select, options));
2600
+ };
2601
+ }
2602
+ function hideInstallCallout() {
2603
+ installCalloutEl.hidden = true;
2604
+ installBtn.onclick = null;
2605
+ }
2606
+ async function offerOpenExisting() {
2607
+ const existing = (await findRelatedHere()).sameLocation[0]?.script;
2608
+ if (existing)
2609
+ showOpenButton(existing.id);
2610
+ }
2611
+ // Stored rows at the location being previewed, via the same lookup the
2612
+ // versions panel uses — one answer to "do we already have
2613
+ // this?", so the buttons and the panel agree by construction.
2614
+ //
2615
+ // "Agree" means the RULE is shared, not the instant: this runs its own
2616
+ // read, so a write landing between it and the panel's paint can leave
2617
+ // the two a moment apart. Deliberate — caching the panel's snapshot to
2618
+ // close that window would hand the buttons a stale answer after an
2619
+ // install or a refresh, which is a worse failure than a transient one
2620
+ // that the next storage change corrects.
2621
+ //
2622
+ // The header IS passed, but only the banner and the table read what it
2623
+ // adds. The install offer comes from `sameLocation` / `installOptions`,
2624
+ // which the header cannot change: installing over a same-`@name` copy
2625
+ // from another URL is not a thing we can do, so letting an identity
2626
+ // match suppress Install would strand the user with no way to add the
2627
+ // script. What the identity match is for is telling the user WHICH
2628
+ // copies exist.
2629
+ async function findRelatedHere() {
2630
+ const snapshot = await readStoreSnapshot();
2631
+ return findRelatedVersions({ ...snapshot, location: previewLocation, header: previewHeader });
2632
+ }
2633
+ async function readStoreSnapshot() {
2634
+ const [scripts, collections] = await Promise.all([getAllScriptMetas(), getCollections()]);
2635
+ return { scripts, collections };
2636
+ }
2637
+ function showOpenButton(id) {
2638
+ openBtn.hidden = false;
2639
+ openBtn.onclick = () => {
2640
+ location.href = viewerUrl(id);
2641
+ };
2642
+ }
2643
+ // `btn` is whichever control the user actually clicked: the callout's
2644
+ // Install, or the panel's offer-row Install. The busy state has to
2645
+ // land on that one — spinning the header button while the clicked one
2646
+ // stays live invites a second click, and a second install.
2647
+ //
2648
+ // `choice` is the dropdown's answer: a listed container (or the
2649
+ // not-yet-existing default), or "New collection…" — which asks the
2650
+ // dialog first and installs into what it made. `undefined` for a
2651
+ // location with no single-file spelling, which `addCollection` adds as
2652
+ // a whole collection.
2653
+ async function doInstall(btn, choice) {
2654
+ let target;
2655
+ if (choice && 'newCollection' in choice) {
2656
+ // Deliberately here rather than when the dropdown changed: a
2657
+ // keyboard user cursoring past "New collection…" fires `change` on
2658
+ // a closed select, and a modal opening under the arrow keys is
2659
+ // startling. Asking at Install also makes the coupling plain —
2660
+ // the dialog says it will create AND install, and its button
2661
+ // agrees.
2662
+ const outcome = await openCollectionDialog({
2663
+ mode: 'create',
2664
+ scriptsOnly: true,
2665
+ createPurpose: 'install',
2666
+ });
2667
+ if (!outcome)
2668
+ return;
2669
+ target = { collectionId: outcome.collection.id };
2670
+ }
2671
+ else if (choice) {
2672
+ target = choice.option.collection
2673
+ ? { collectionId: choice.option.collection.id }
2674
+ : { schemeDefault: true };
2675
+ }
2676
+ const spinner = startBusy(btn);
2677
+ try {
2678
+ // The normalized form, not the raw input: the store normalizes
2679
+ // again internally (a no-op on an already-canonical value), and for
2680
+ // a github blob URL the raw input would cost a second Contents-API
2681
+ // round trip that can fail after the user has already clicked
2682
+ // Install.
2683
+ const result = target
2684
+ ? await installScript(previewLocation, target)
2685
+ : await addCollection(previewLocation);
2686
+ if (!result) {
2687
+ // Raced another tab (or the same URL under a different spelling
2688
+ // that normalizes to an already-installed one). Re-run the
2689
+ // wiring so the page converges on the table instead of offering
2690
+ // an Install that can only repeat this message.
2691
+ setMessage(ALREADY_INSTALLED_IN_TARGET);
2692
+ await wireInstallButtons();
2693
+ return;
2694
+ }
2695
+ const scriptId = result.kind === 'existingContainer'
2696
+ ? result.scriptId
2697
+ : result.collection.scriptIds[0];
2698
+ // Only a single-file install has one script to speak for. The
2699
+ // preview keeps its Install button for other shapes too (a bare
2700
+ // `.js` file, a manifest URL), and those land a whole collection
2701
+ // whose `scriptIds[0]` is whatever the scan ordered first —
2702
+ // arbitrary enough for a navigation target, not something to
2703
+ // rewrite enable state from.
2704
+ if (scriptId && isSingleFileInput(previewLocation))
2705
+ await soloRunningCopy(scriptId);
2706
+ location.href = scriptId
2707
+ ? viewerUrl(scriptId)
2708
+ : 'collections.html';
2709
+ }
2710
+ catch (err) {
2711
+ setMessage(`Install failed: ${describeError(err)}`, 'error');
2712
+ }
2713
+ finally {
2714
+ spinner.remove();
2715
+ btn.disabled = false;
2716
+ }
2717
+ }
2718
+ // ---------- Move (installed viewer) ----------
2719
+ // What the last panel paint learned, reused by the Move picker so it
2720
+ // opens with no I/O at all.
2721
+ //
2722
+ // Why it can't just read the store: `readStoreSnapshot` is a
2723
+ // `storage.local.get(null)`, which deserializes EVERY row — including
2724
+ // every script's full source and every `gmval:` value — to answer a
2725
+ // question about `fileUrl` and `collectionId`. That is a visible pause
2726
+ // on a click, and the panel has already paid it. Written by every
2727
+ // successful paint and never cleared, so after a paint that failed it
2728
+ // holds the last good answer rather than nothing — which is the right
2729
+ // trade for a list the store re-validates anyway: `moveScript` checks
2730
+ // under the write lock, so a stale entry costs a message, never a bad
2731
+ // move.
2732
+ let moveTargetContext = null;
2733
+ // The Move button, to the right of the Collection row's display name.
2734
+ // Everything it does happens in the move dialog (src/extension/move-dialog.ts);
2735
+ // this is the affordance and the store write that follows.
2736
+ //
2737
+ // Only for a script inside a container ("individual scripts")
2738
+ // collection: a directory or manifest script's `relativePath` is
2739
+ // relative to its collection root, so it has no absolute spelling to
2740
+ // carry into a container. `moveScript` refuses those too.
2741
+ // The controls in the row under the heading, between the Enabled box
2742
+ // and Refresh: Local copy… for every script, and the trash can for a
2743
+ // script that IS a local copy. They act on the script the page is
2744
+ // showing, which is what the rest of that row is about — the Collection
2745
+ // row below keeps only Move…, which acts on the collection membership.
2746
+ //
2747
+ // A collection row we couldn't read costs the trash can, not the whole
2748
+ // group: without it there is no way to tell a local copy from any other
2749
+ // script.
2750
+ function makeScriptActions(script, collection) {
2751
+ const actions = [makeLocalCopyControl(script)];
2752
+ if (collection)
2753
+ actions.push(makeDeleteCopyControl(script, collection));
2754
+ return actions;
2755
+ }
2756
+ // The trash can beside Local copy…, for a script that IS a local copy:
2757
+ // its file sits in the Downloads copies directory, which is the one
2758
+ // place SourceMonkey can delete a file from. Everything it does happens
2759
+ // in the delete dialog (src/extension/delete-dialog.ts); this is the affordance,
2760
+ // and the record removal that follows the file's.
2761
+ //
2762
+ // Hidden until the setting says the file qualifies. The row is painted
2763
+ // synchronously and the answer needs a storage read, so the button is
2764
+ // built off-screen and revealed rather than the whole row waiting on
2765
+ // it — a script that isn't a copy never shows one at all.
2766
+ function makeDeleteCopyControl(script, collection) {
2767
+ const btn = document.createElement('button');
2768
+ btn.type = 'button';
2769
+ btn.id = 'delete-copy-btn';
2770
+ btn.className = 'remove-script';
2771
+ setDeleteIcon(btn, 'file');
2772
+ btn.hidden = true;
2773
+ // Not the plain "Remove this script record" of the other trash cans:
2774
+ // this one takes the file off the disk as well. The badged glyph
2775
+ // above says that at a glance; the tooltip spells it out.
2776
+ btn.title = 'Delete local script file';
2777
+ btn.setAttribute('aria-label', 'Delete local script file');
2778
+ void readCopiesSetting().then((setting) => {
2779
+ btn.hidden = deletableCopyPath(script, collection, setting) === null;
2780
+ });
2781
+ btn.onclick = async () => {
2782
+ const result = await openDeleteDialog({ script });
2783
+ if (!result)
2784
+ return;
2785
+ // The record goes with the file, explicitly. A refresh would find
2786
+ // the file gone and drop the row too, but it would cost a scan of
2787
+ // the directory, and we already know the answer.
2788
+ //
2789
+ // Both flags are set BEFORE the removal, not before the rehydrate:
2790
+ // the removal's own storage event can be delivered while the write
2791
+ // is still in flight, and a not-found state painted by that
2792
+ // listener has to read as the result the user asked for — see
2793
+ // `removedHereMessage` and the panel's own `remove`.
2794
+ removedHereMessage = REMOVED_HERE;
2795
+ removedHereDetail = result.dependencyErrors;
2796
+ try {
2797
+ await removeScript(result.scriptId);
2798
+ }
2799
+ catch (err) {
2800
+ removedHereMessage = null;
2801
+ removedHereDetail = [];
2802
+ setMessage(`The file was deleted, but its record could not be removed: ${describeError(err)}`, 'error');
2803
+ return;
2804
+ }
2805
+ await rehydrateAfterStorageChange();
2806
+ };
2807
+ return btn;
2808
+ }
2809
+ // Local copy…: write an editable copy of this script into the
2810
+ // Downloads collection, switch every other copy off, and show the new
2811
+ // one. The dialog owns the write and its failures — see
2812
+ // `src/extension/copy-dialog.ts` and docs/local-copy.md.
2813
+ function makeLocalCopyControl(script) {
2814
+ const btn = document.createElement('button');
2815
+ btn.type = 'button';
2816
+ btn.id = 'local-copy-btn';
2817
+ btn.textContent = 'Local copy…';
2818
+ // An `invalid` row has no usable header — often no metadata block at
2819
+ // all — so a copy of it would be written with none of the header
2820
+ // edits the dialog offers and would land invalid in its turn.
2821
+ // `missing` is allowed: the row still holds the last content that
2822
+ // loaded, and rescuing it onto disk is a reason to be here.
2823
+ btn.disabled = script.status === 'invalid';
2824
+ btn.title = btn.disabled
2825
+ ? 'This script did not load, so there is nothing to copy'
2826
+ : 'Make an editable copy of this script on local disk';
2827
+ btn.onclick = async () => {
2828
+ const result = await openCopyDialog({ script });
2829
+ if (!result)
2830
+ return;
2831
+ // The copy is the version the user wants running now — the same
2832
+ // rule an install follows.
2833
+ await soloRunningCopy(result.script.id);
2834
+ await swapToScript(result.script.id);
2835
+ };
2836
+ return btn;
2837
+ }
2838
+ function makeMoveControl(script, collection) {
2839
+ if (!isContainerCollection(collection.path) || isSmDevCollection(collection.path))
2840
+ return undefined;
2841
+ const btn = document.createElement('button');
2842
+ btn.type = 'button';
2843
+ btn.id = 'move-collection-btn';
2844
+ btn.className = 'collection-move';
2845
+ btn.textContent = 'Move…';
2846
+ btn.title = 'Move this script to another collection';
2847
+ btn.onclick = () => void openMove(btn);
2848
+ // Everywhere an install could land, with the collection the script
2849
+ // is in now marked as the current one — listed, disabled, and what
2850
+ // the dialog opens on.
2851
+ const targetsFrom = (ctx) => installOptionsFor(locationKey(script.fileUrl), ctx.sameLocation, ctx.collections, collection.id);
2852
+ const openMove = async (button) => {
2853
+ let options = moveTargetContext ? targetsFrom(moveTargetContext) : undefined;
2854
+ // Only before the first panel paint has landed — it is fired off by
2855
+ // the same render that built this button, so the user has to be
2856
+ // quick. Reading the store here costs the pause the cache exists to
2857
+ // avoid, which is why it is the fallback and not the path.
2858
+ if (!moveTargetContext) {
2859
+ const spinner = startBusy(button);
2860
+ try {
2861
+ const snapshot = await readStoreSnapshot();
2862
+ const related = findRelatedVersions({
2863
+ ...snapshot,
2864
+ location: script.fileUrl,
2865
+ header: script.header ?? null,
2866
+ });
2867
+ options = targetsFrom({
2868
+ collections: snapshot.collections, sameLocation: related.sameLocation,
2869
+ });
2870
+ }
2871
+ catch (err) {
2872
+ setMessage(`Could not list collections: ${describeError(err)}`, 'error');
2873
+ return;
2874
+ }
2875
+ finally {
2876
+ spinner.remove();
2877
+ button.disabled = false;
2878
+ }
2879
+ }
2880
+ if (!options) {
2881
+ // `installOptionsFor` declines anything that isn't a single-file
2882
+ // location, and every container row is one — so a row that lands
2883
+ // here was written by something that shouldn't have.
2884
+ setMessage(MOVE_NOT_AVAILABLE, 'error');
2885
+ return;
2886
+ }
2887
+ // The dialog owns the move itself, errors included; it resolves
2888
+ // only once something actually moved.
2889
+ const moved = await openMoveDialog({ scriptId: script.id, options });
2890
+ if (!moved)
2891
+ return;
2892
+ // This control closed over the collection the script WAS in, so
2893
+ // until the repaint replaces it a second click would build the
2894
+ // list with the wrong `current`. The repaint is already on its
2895
+ // way; retiring the button is what covers the gap.
2896
+ button.disabled = true;
2897
+ // A move out of a disabled collection into an enabled one starts
2898
+ // the script, which makes it the copy that should be running.
2899
+ await soloRunningCopy(script.id);
2900
+ // Nothing to repaint by hand: the row's `collectionId` changed, so
2901
+ // the page's own `storage.onChanged` listener rehydrates the
2902
+ // header — Collection row, Move button and all.
2903
+ };
2904
+ return btn;
2905
+ }
2906
+ // A row whose stored location isn't a single-file one, which no
2907
+ // container row should ever hold.
2908
+ const MOVE_NOT_AVAILABLE = 'Internal error: this script has no single-file location to move';
2909
+ // The exclusive-enable sweep for a copy that just started running (see
2910
+ // `turnOffOtherCopies`): after an install, and after a move into an
2911
+ // enabled collection.
2912
+ //
2913
+ // Fails open and never blocks the navigation to the new script's page.
2914
+ // The install itself succeeded; if the sweep didn't, the panel on that
2915
+ // page is right there to fix the enable state by hand.
2916
+ async function soloRunningCopy(scriptId) {
2917
+ try {
2918
+ await turnOffOtherCopies(scriptId);
2919
+ }
2920
+ catch (err) {
2921
+ dlog(`[SourceMonkey] exclusive-enable sweep skipped: ${describeError(err)}`);
2922
+ }
2923
+ }
2924
+ // Show the unprocessed file in a new tab, leaving the preview open
2925
+ // behind it.
2926
+ //
2927
+ // This is also the escape hatch for a `.user.js` the user opened to
2928
+ // *read* rather than install — the cost of capturing `file://` — so it
2929
+ // has to work whether or not the fetch succeeded.
2930
+ //
2931
+ // Always a real navigation, on every scheme: Chrome renders a
2932
+ // `.user.js` as source text whether it came over http or off disk, so
2933
+ // the tab shows the actual file at its actual address.
2934
+ //
2935
+ // Which address depends on whether we could read the location:
2936
+ //
2937
+ // - **Read it** — the canonical location, not what the user typed.
2938
+ // A GitHub blob URL was rewritten to its raw counterpart before we
2939
+ // fetched it, and the raw file is the source this page describes;
2940
+ // navigating to the blob URL would open GitHub's file browser
2941
+ // instead.
2942
+ // - **Couldn't** — the user's own input, when it is navigable. That
2943
+ // is the form that can carry site cookies our fetch doesn't: a
2944
+ // private repo's blob page renders for a logged-in user, where its
2945
+ // `raw.githubusercontent.com` counterpart 404s without a token.
2946
+ // So the failed-fetch case is also the retry, which is the whole
2947
+ // reason this button has to work when nothing was fetched.
2948
+ //
2949
+ // The two agree except where canonicalization rewrote the URL, which
2950
+ // today means GitHub blob links added through the collections page.
2951
+ //
2952
+ // `previewFileUrl` is empty until `normalizeCollectionInput` resolves
2953
+ // one, and stays empty if it threw — the only case with no address to
2954
+ // offer at all, and the only one that reports instead of navigating.
2955
+ //
2956
+ // `navigateRaw` sets a one-shot capture bypass first, `file://`
2957
+ // included: the capture covers local files, so an unbypassed
2958
+ // navigation would be pulled straight back into this same preview.
2959
+ function openRawSource() {
2960
+ const typed = isHttpUrl(previewInput ?? '') ? (previewInput ?? '') : '';
2961
+ const url = previewText ? previewFileUrl : typed || previewFileUrl;
2962
+ if (!url) {
2963
+ setMessage('There is no source to open for this location', 'error');
2964
+ return;
2965
+ }
2966
+ navigateRaw(url);
2967
+ }
2968
+ // Every navigation the preview initiates goes through here: stand the
2969
+ // capture down for that one URL, then go.
2970
+ //
2971
+ // The bypass is keyed on the URL the browser will actually request,
2972
+ // not on `previewInput` — for a local file opened by path the two
2973
+ // differ (a bare path isn't navigable, only its `file://` form is).
2974
+ // The `go` step runs only after the bypass row lands: the capture
2975
+ // listener reads it from the service worker, and an unmarked
2976
+ // navigation would be pulled straight back into this preview.
2977
+ //
2978
+ // `chrome.storage.session` is quota-limited and shared with the
2979
+ // per-script logs, so the write can genuinely fail; say so rather than
2980
+ // leaving the button inert.
2981
+ function withBypass(url, go) {
2982
+ markInstallBypass(url)
2983
+ .then(() => go(url))
2984
+ .catch((err) => {
2985
+ setMessage(`Could not open ${url}: ${describeError(err)}`, 'error');
2986
+ // Chrome refuses to navigate a tab to a `file://` URL while
2987
+ // "Allow access to file URLs" is off, and says so in terms that
2988
+ // don't name the setting. The banner does.
2989
+ showFileAccessBannerIfBlocked(url);
2990
+ });
2991
+ }
2992
+ // Open the real URL in a new tab, leaving this preview in place.
2993
+ //
2994
+ // The `tabs.create` promise is handed back to `withBypass` rather than
2995
+ // dropped: it rejects for reasons the user can act on — a `file://`
2996
+ // target with "Allow access to file URLs" off is refused outright —
2997
+ // and an unhandled rejection in an extension page is reported into
2998
+ // chrome://extensions/?errors, where it reads as SourceMonkey being
2999
+ // broken instead of a setting being off.
3000
+ function navigateRaw(url) {
3001
+ withBypass(url, (target) => chrome.tabs.create({ url: target }).then(() => undefined));
3002
+ }
3003
+ // Cancel: back to wherever the click came from. A captured navigation
3004
+ // never committed, so the previous entry is the referring page. A tab
3005
+ // opened straight into the preview (target=_blank from a site's install
3006
+ // button) has no history to go back to, so close it — via
3007
+ // `chrome.tabs.remove`, because Chrome refuses `window.close()` for a
3008
+ // tab that wasn't opened by script.
3009
+ function leavePage() {
3010
+ if (history.length > 1) {
3011
+ history.back();
3012
+ return;
3013
+ }
3014
+ void chrome.tabs.getCurrent().then((tab) => {
3015
+ if (tab?.id != null)
3016
+ void chrome.tabs.remove(tab.id);
3017
+ });
3018
+ }
3019
+ // Show the collections page's file-access banner when a local read
3020
+ // failed *and* the toggle is the plausible reason.
3021
+ //
3022
+ // Chrome reports "toggle off" and "no such file" as the same opaque
3023
+ // fetch failure, so the error text alone can't tell them apart — the
3024
+ // toggle state can. Gated on the URL being `file://` so an http fetch
3025
+ // that failed for its own reasons doesn't get blamed on a toggle that
3026
+ // wouldn't have helped it.
3027
+ function showFileAccessBannerIfBlocked(fileUrl, retry) {
3028
+ if (fileAccessAllowed || !isFileUrl(fileUrl))
3029
+ return;
3030
+ replaceBanner(renderFileAccessBanner(retry));
3031
+ }
3032
+ // Whether the error this row is flagged with is about *reading* the
3033
+ // file, as opposed to what we read once it arrived.
3034
+ //
3035
+ // The distinction matters because only the first kind is something the
3036
+ // toggle explains. A local script flagged for a header problem or a
3037
+ // syntax error is flagged for a reason the toggle had nothing to do
3038
+ // with, and putting the banner under that message would offer an
3039
+ // unrelated setting as the explanation.
3040
+ //
3041
+ // Told apart by the URL: every fetch helper in `directories.ts` names
3042
+ // the thing it couldn't read (`Could not read <url>: …`), and no parse
3043
+ // or validation message does. Still true of rows persisted under the
3044
+ // older wording, which named the URL too. Deliberately
3045
+ // matched against this script's own `fileUrl`, so a failed `@require`
3046
+ // against some other host doesn't count either.
3047
+ function readFailed(script) {
3048
+ const message = script.errorMessage ?? script.refreshError;
3049
+ return message !== undefined && message.includes(script.fileUrl);
3050
+ }
3051
+ function renderBanner(content, extraClass = '') {
3052
+ replaceBanner(makeBanner(content, extraClass));
3053
+ }
3054
+ // The one way to replace what the banner slot says. Every caller
3055
+ // REPLACES rather than appends, and each one can run after the parse
3056
+ // check has reported — `doInstall` discovering the script is already
3057
+ // installed, say — so each would otherwise silently delete a "this has
3058
+ // a syntax error" warning the user is mid-way through reading. Going
3059
+ // through here means a new banner path can't reintroduce that.
3060
+ function replaceBanner(banner) {
3061
+ // Dropped on every path, and re-set by the one banner that wants it.
3062
+ // The merged box belongs to "this script is already installed"; a
3063
+ // file-access or parse banner replacing it must not inherit a green
3064
+ // card drawn around a table it says nothing about.
3065
+ statusBoxEl.classList.remove('merged');
3066
+ bannerSlotEl.replaceChildren(banner);
3067
+ for (const warning of previewWarnings)
3068
+ bannerSlotEl.append(makeBanner(warning));
3069
+ }
3070
+ function makeBanner(content, extraClass = '') {
3071
+ const div = document.createElement('div');
3072
+ div.className = extraClass ? `banner ${extraClass}` : 'banner';
3073
+ const p = document.createElement('p');
3074
+ if (typeof content === 'string') {
3075
+ p.textContent = content;
3076
+ }
3077
+ else {
3078
+ if (content.lead) {
3079
+ const lead = document.createElement('strong');
3080
+ lead.textContent = content.lead;
3081
+ p.append(lead, ' ');
3082
+ }
3083
+ appendDiagnostic(p, content.message, content.location);
3084
+ if (content.trailing)
3085
+ p.append(content.trailing);
3086
+ }
3087
+ div.append(p);
3088
+ return div;
3089
+ }
3090
+ // Any copy exists — here, from a mirror, or several. Which ones, and
3091
+ // what each can do, is the table's job — so the table is drawn inside
3092
+ // this banner's box rather than under it (the `merged` class), and the
3093
+ // wording names the choice without having to point anywhere.
3094
+ //
3095
+ // "instance", not "version": the table has a Version column holding
3096
+ // `@version` strings, and asking the user to pick a version next to it
3097
+ // would read as picking one of those rather than one of the rows.
3098
+ function renderInstalledBanner() {
3099
+ // One line: the bold statement and what to do about it, with the
3100
+ // table of copies directly under it inside the same box. Two
3101
+ // sentences, so unlike every other user-visible string in the
3102
+ // extension this one is punctuated — see docs/diagnostics.md#wording.
3103
+ replaceBanner(makeBanner({
3104
+ lead: 'This script is already installed.',
3105
+ message: 'Select which instance to update or install.',
3106
+ }, 'installed'));
3107
+ statusBoxEl.classList.add('merged');
3108
+ }
3109
+ // No banner — the install callout carries the page's question — but
3110
+ // the parse check's warnings stay.
3111
+ function clearBanner() {
3112
+ statusBoxEl.classList.remove('merged');
3113
+ bannerSlotEl.replaceChildren();
3114
+ for (const warning of previewWarnings)
3115
+ bannerSlotEl.append(makeBanner(warning));
3116
+ }
3117
+ function startBusy(btn) {
3118
+ btn.disabled = true;
3119
+ const spinner = document.createElement('span');
3120
+ spinner.className = 'busy-spinner';
3121
+ btn.after(spinner);
3122
+ return spinner;
3123
+ }
3124
+ // ---------- Shared rendering ----------
3125
+ // `<prefix>` `<favicon>` `<label>`. A label that fell back to the
3126
+ // location is percent-decoded — for a script installed from (or
3127
+ // previewed at) a URL the encoded form is unreadable. renderSiteIconInto
3128
+ // paints a clickable globe placeholder synchronously (when the script
3129
+ // targets a usable origin) and swaps it for the real favicon once the
3130
+ // fetch resolves.
3131
+ function renderHeading(prefix, label,
3132
+ // The script's location. Not drawn — it goes on the heading's
3133
+ // `aria-label`, for the one case the visible heading can't tell
3134
+ // apart: two installed copies of one script share an `@name`, and
3135
+ // switching between them from the versions panel repaints in place —
3136
+ // with focus parked on this `<h1>` whenever the switch didn't come
3137
+ // from the table itself (Back / Forward; see `showSelectedScript`).
3138
+ // Without it a screen reader hears the same heading before and after
3139
+ // the switch. Dropped when the label already IS the location.
3140
+ location, script,
3141
+ // Local override scope for the script's `@icon`. Installed mode passes
3142
+ // its collection's; the preview passes a bare `enabled` outright, the
3143
+ // same way its `@require` / `@resource` resolution does — a file the
3144
+ // user pointed at directly has no collection row to consult.
3145
+ localFiles = { enabled: true }) {
3146
+ notePainted();
3147
+ filenameEl.replaceChildren();
3148
+ filenameEl.appendChild(headingPrefixEl(prefix));
3149
+ const iconSlot = document.createElement('span');
3150
+ iconSlot.className = 'site-icon';
3151
+ filenameEl.appendChild(iconSlot);
3152
+ appendWithSlashBreaks(filenameEl, label);
3153
+ // An aria-label rather than a clipped span: the location must not
3154
+ // land in `textContent`, which is what a copy/paste of the heading —
3155
+ // and every test assertion on it — reads. The comma is there so a
3156
+ // screen reader pauses instead of running the path onto the name.
3157
+ if (location && location !== label) {
3158
+ filenameEl.setAttribute('aria-label', `${prefix}${label}, ${location}`);
3159
+ }
3160
+ else {
3161
+ filenameEl.removeAttribute('aria-label');
3162
+ }
3163
+ void renderSiteIconInto(iconSlot, script, localSearchRoots(script.fileUrl, localFiles), HEADING_SITE_ICONS);
3164
+ }
3165
+ function setMessage(text, kind = '') {
3166
+ messageEl.textContent = text;
3167
+ messageEl.className = kind ? `message ${kind}` : 'message';
3168
+ }
3169
+ // ---------- Clickable diagnostics ----------
3170
+ //
3171
+ // Everything SourceMonkey concludes from a script's *content* — a
3172
+ // metadata block that never closes, a missing `@match`, a `@require`
3173
+ // that won't load, a syntax error — arrives with a `DiagnosticLocation`
3174
+ // naming the file and usually the line. This is the half that turns
3175
+ // that into a place the reader can click through to.
3176
+ // The file identity a diagnostic uses for the script's OWN body: its
3177
+ // `relativePath` when installed, the previewed location otherwise.
3178
+ //
3179
+ // Only the main script needs recording. A `@require` is named by its
3180
+ // resolved URL in both a diagnostic and its tab's `fileUrl`, so those
3181
+ // match directly — but the main tab's `fileUrl` is the script's
3182
+ // *fetchable* URL (absolute `file://` / `https://`), which is not what
3183
+ // the source map calls it.
3184
+ let mainDiagFile = '';
3185
+ function findTabForLocation(file) {
3186
+ if (file && file === mainDiagFile)
3187
+ return 0;
3188
+ return tabsView.tabs.findIndex((t) => t.kind === 'code' && t.fileUrl === file);
3189
+ }
3190
+ // Paint `<message> (in <where>)` into `el`, with the "where" a link
3191
+ // that selects the tab and highlights the line.
3192
+ //
3193
+ // Falls back to exactly the string `describeDiagnostic` produces
3194
+ // whenever there is no tab for the file. That is a real case rather
3195
+ // than a defensive one: a `@require` whose fetch failed never gets a
3196
+ // tab, and telling the reader which declaration is at fault is worth
3197
+ // having even when we can't take them to it.
3198
+ function appendDiagnostic(el, message, location) {
3199
+ const tabIndex = location ? findTabForLocation(location.file) : -1;
3200
+ const tab = tabIndex >= 0 ? tabsView.tabs[tabIndex] : undefined;
3201
+ if (!location || !tab || tab.kind !== 'code') {
3202
+ el.append(describeDiagnostic(message, location));
3203
+ return;
3204
+ }
3205
+ el.append(`${message} (in `);
3206
+ const a = document.createElement('a');
3207
+ a.className = 'diagnostic-link';
3208
+ // A real fragment rather than `href="#"`, so the link is copyable and
3209
+ // shows where it goes. It names the same place the click handler
3210
+ // navigates to — `goToTabLine` rebuilds this exact hash afterwards.
3211
+ const fragment = buildHash({
3212
+ tab: tabIndex === 0 ? 'main' : 'require',
3213
+ filename: tab.label,
3214
+ line: location.line,
3215
+ col: location.column,
3216
+ });
3217
+ a.href = fragment ? `#${fragment}` : '#';
3218
+ a.textContent = locationLabel(location);
3219
+ a.addEventListener('click', (ev) => {
3220
+ // Navigating in the handler rather than letting the fragment do it:
3221
+ // clicking a link whose hash already matches the URL fires no
3222
+ // `hashchange`, so a second click after scrolling away would sit
3223
+ // there doing nothing.
3224
+ ev.preventDefault();
3225
+ tabsView.goToTabLine(tabIndex, location.line, location.column);
3226
+ });
3227
+ el.append(a, ')');
3228
+ }
3229
+ // `setMessage` for a diagnostic that knows where it came from.
3230
+ function setDiagnosticMessage(message, location, kind) {
3231
+ setMessage('', kind);
3232
+ appendDiagnostic(messageEl, message, location);
3233
+ }
3234
+ function showMissing(message) {
3235
+ currentScript = null;
3236
+ // Focus standing in the panel is about to be destroyed with it, and
3237
+ // no paint will run to put it anywhere (see `lastRelatedPaint` below)
3238
+ // — so the heading takes it, same as every other path that loses the
3239
+ // row it was going to.
3240
+ const focusInPanel = versionsEl.contains(document.activeElement);
3241
+ controlsRowEl.hidden = true;
3242
+ actionsEl.hidden = true;
3243
+ // With the rest: both controls close over the row that has gone, and
3244
+ // the row they sit in is only hidden, not emptied.
3245
+ metaActionsEl.replaceChildren();
3246
+ bannerSlotEl.replaceChildren();
3247
+ // No script to name, but the heading keeps its product prefix so it
3248
+ // doesn't change shape between this and a script that loaded.
3249
+ //
3250
+ // The bare product prefix in BOTH modes: the preview's carries the
3251
+ // verb ("SourceMonkey | Install | "), and there is nothing here for
3252
+ // it to offer — with no script to install, dropping the verb stops
3253
+ // the heading promising an offer this page isn't making.
3254
+ notePainted();
3255
+ filenameEl.replaceChildren();
3256
+ filenameEl.removeAttribute('aria-label');
3257
+ filenameEl.append(headingPrefixEl(INSTALLED_HEADING), MISSING_LABEL);
3258
+ // The title too, or the tab keeps advertising a script that is gone —
3259
+ // this path is reached by removing the script the viewer is open on,
3260
+ // not only by opening a bad URL.
3261
+ setPageTitle(INSTALLED_TITLE, MISSING_LABEL);
3262
+ setMessage(message, 'error');
3263
+ summaryEl.replaceChildren();
3264
+ // The panel goes with the rest. Its rows carry live toggles and trash
3265
+ // cans keyed to script ids, and its heading — "Other versions of THIS
3266
+ // script" — has nothing left to be other than. Reached when another
3267
+ // tab removes the script out from under us, and when the panel's own
3268
+ // trash can takes the last copy there was to switch to.
3269
+ versionsEl.replaceChildren();
3270
+ // And the bookkeeping that would paint it back: a `storage.session`
3271
+ // change matching one of the ids we just wiped would otherwise
3272
+ // rebuild the table on top of the "not available" page.
3273
+ lastRelatedPaint = null;
3274
+ lastRelatedIds = new Set();
3275
+ statusBoxEl.classList.remove('merged');
3276
+ logPaneEl.replaceChildren();
3277
+ tabsView.clear();
3278
+ pendingRowFocusId = '';
3279
+ if (focusInPanel)
3280
+ filenameEl.focus();
3281
+ }
3282
+ // ---------- Log pane (installed scripts only) ----------
3283
+ // Bumped on every paint request (tab-switch initial paint AND storage-
3284
+ // change repaint). renderLogPane awaits a storage.get; a sm:* event
3285
+ // landing during that await would call paintLog with the new array, and
3286
+ // the older read would then resolve and overwrite with the stale array.
3287
+ // Each paint path captures its epoch and bails if a fresher paint
3288
+ // already ran.
3289
+ let logPaintEpoch = 0;
3290
+ async function renderLogPane() {
3291
+ if (!scriptId)
3292
+ return;
3293
+ const epoch = ++logPaintEpoch;
3294
+ const entries = await getScriptLog(scriptId);
3295
+ if (epoch !== logPaintEpoch)
3296
+ return;
3297
+ paintLog(entries);
3298
+ }
3299
+ function paintLog(entries) {
3300
+ logPaneEl.replaceChildren();
3301
+ // Toolbar with the log-wide actions. Sticky so they stay visible as
3302
+ // the user scrolls through a long log. Clear stays rightmost — it is
3303
+ // the destructive one, and it was there before Save/Copy joined it,
3304
+ // so its position doesn't move under anyone's cursor.
3305
+ const toolbar = document.createElement('div');
3306
+ toolbar.className = 'log-toolbar';
3307
+ // Both carry over whatever outcome they are still reporting when a
3308
+ // new log entry forces this repaint — see `reportLogAction`.
3309
+ const saveBtn = logActionButton('save', 'Save this log to a CSV file', () => void onSaveLog(entries));
3310
+ saveBtn.disabled = entries.length === 0;
3311
+ const copyBtn = logActionButton('copy', 'Copy this log to the clipboard as CSV', () => void onCopyLog(entries));
3312
+ copyBtn.disabled = entries.length === 0;
3313
+ const clearBtn = document.createElement('button');
3314
+ clearBtn.type = 'button';
3315
+ clearBtn.textContent = 'Clear log';
3316
+ clearBtn.addEventListener('click', () => {
3317
+ onClearLog().catch((err) => {
3318
+ // Logged, not thrown: a dropped rejection lands in the extension's
3319
+ // Errors panel. The log stays as it was, and so does the pane.
3320
+ console.info('[SourceMonkey] could not clear the log:', describeError(err));
3321
+ });
3322
+ });
3323
+ toolbar.append(saveBtn, copyBtn, clearBtn);
3324
+ logPaneEl.appendChild(toolbar);
3325
+ if (entries.length === 0) {
3326
+ const empty = document.createElement('div');
3327
+ empty.className = 'log-empty';
3328
+ empty.textContent = 'No log entries yet. Refresh the script or trigger an event to populate this view.';
3329
+ logPaneEl.appendChild(empty);
3330
+ return;
3331
+ }
3332
+ const table = document.createElement('table');
3333
+ const thead = document.createElement('thead');
3334
+ const trHead = document.createElement('tr');
3335
+ for (const h of LOG_COLUMNS) {
3336
+ const th = document.createElement('th');
3337
+ th.textContent = h;
3338
+ trHead.appendChild(th);
3339
+ }
3340
+ thead.appendChild(trHead);
3341
+ table.appendChild(thead);
3342
+ const tbody = document.createElement('tbody');
3343
+ // Newest first so recent events are visible without scrolling.
3344
+ for (let i = entries.length - 1; i >= 0; i--) {
3345
+ const e = entries[i];
3346
+ const tr = document.createElement('tr');
3347
+ const tdTs = document.createElement('td');
3348
+ tdTs.className = 'log-ts';
3349
+ tdTs.textContent = formatLogTimestamp(e.timestamp);
3350
+ tr.appendChild(tdTs);
3351
+ const tdLevel = document.createElement('td');
3352
+ tdLevel.className = 'log-level';
3353
+ const badge = document.createElement('span');
3354
+ badge.className = `log-level-badge ${logStatusClass(e)}`;
3355
+ badge.textContent = formatLogStatus(e);
3356
+ tdLevel.appendChild(badge);
3357
+ tr.appendChild(tdLevel);
3358
+ const tdSource = document.createElement('td');
3359
+ tdSource.className = 'log-source';
3360
+ tdSource.textContent = formatLogSource(e);
3361
+ tr.appendChild(tdSource);
3362
+ const tdMsg = document.createElement('td');
3363
+ tdMsg.className = 'log-msg';
3364
+ if (e.location) {
3365
+ // A location SourceMonkey derived (a bad header line, a syntax
3366
+ // error) rather than one scraped out of a stack trace. It is
3367
+ // already structured, so it needs none of the remapping the ref
3368
+ // scanner below does — and the message it belongs to is our own
3369
+ // prose, which has no refs in it to scan for.
3370
+ appendDiagnostic(tdMsg, e.message, e.location);
3371
+ }
3372
+ else {
3373
+ appendLogMessageContent(tdMsg, e.message, e.timestamp);
3374
+ }
3375
+ tr.appendChild(tdMsg);
3376
+ tbody.appendChild(tr);
3377
+ }
3378
+ table.appendChild(tbody);
3379
+ logPaneEl.appendChild(table);
3380
+ }
3381
+ // Tooltip note for a ref mapped with a layout that isn't the one it
3382
+ // was recorded under. See `resolveLogRef`.
3383
+ const STALE_REF_NOTE = 'Recorded before the current version of this script, so the file and line may not match.';
3384
+ function resolveLogRef(ref, timestamp) {
3385
+ if (ref.url !== stackEntryUrl)
3386
+ return { kind: 'asIs' };
3387
+ if (stackSegments.length === 0) {
3388
+ // No requires: generated line N is original line N, except the
3389
+ // wrap's closing line just past the body (see `stackBodyLineCount`).
3390
+ return ref.line > stackBodyLineCount ? { kind: 'unmappable' } : { kind: 'asIs' };
3391
+ }
3392
+ const mapped = remapStackRef(ref, stackEntryUrl, stackSegments);
3393
+ if (!mapped)
3394
+ return { kind: 'unmappable' };
3395
+ // The layout is recomputed from the CURRENT stored script, but the
3396
+ // log outlives refreshes — nothing clears it but the user's Clear
3397
+ // button. An entry recorded before this body loaded was generated
3398
+ // against a different arrangement of requires, so today's segments
3399
+ // may well name the wrong file and line for it.
3400
+ //
3401
+ // Mapped anyway, and still clickable. The alternative — leaving it
3402
+ // at the generated coordinate — is not the honest option it looks
3403
+ // like: that number indexes the concatenated program, so it is
3404
+ // wrong too, just less legibly, and it points into the `.user.js` at
3405
+ // an offset that includes every preceding `@require`. A best-effort
3406
+ // file and line at least lands the user somewhere they can judge.
3407
+ // The tooltip says the entry is older than the body it was mapped
3408
+ // against, so a mismatch reads as expected rather than as a bug.
3409
+ //
3410
+ // The old layout cannot be recovered to do better: a refresh points
3411
+ // the row at the new body and `@require` blobs, keeping no reference
3412
+ // to the old ones, and a `LogEntry` stores no layout of its own.
3413
+ //
3414
+ // The staleness signal is not airtight either: an already-open tab
3415
+ // still running the previous body can log an entry with a fresh
3416
+ // timestamp, which reads as current. Documented in
3417
+ // docs/script-log.md; closing it would need the wrapper to stamp
3418
+ // reports with the loadedAt they ran under.
3419
+ const loadedAt = currentScript?.loadedAt;
3420
+ const stale = loadedAt !== undefined && timestamp < loadedAt;
3421
+ return { kind: 'mapped', ref: mapped, stale };
3422
+ }
3423
+ // Paint a ref into `el` with the wire form kept as a tooltip whenever
3424
+ // decoding changed it — the viewer's standing rule is that the encoded
3425
+ // form is always one hover away (docs/names-and-paths.md). It earns its
3426
+ // keep here beyond consistency: devtools' Sources tree indexes the file
3427
+ // under the ENCODED folder name, so the tooltip is what a user needs to
3428
+ // find the same file there.
3429
+ function setRefText(el, run) {
3430
+ el.textContent = run.shown;
3431
+ const title = refTitle(run);
3432
+ if (title)
3433
+ el.title = title;
3434
+ }
3435
+ // A ref with no tab to link to. Stays a bare text node unless there is
3436
+ // a tooltip to carry, which needs an element.
3437
+ function refNode(run) {
3438
+ const title = refTitle(run);
3439
+ if (!title)
3440
+ return document.createTextNode(run.shown);
3441
+ const span = document.createElement('span');
3442
+ span.textContent = run.shown;
3443
+ span.title = title;
3444
+ return span;
3445
+ }
3446
+ // What a ref's tooltip should say: the wire form when decoding changed
3447
+ // what is on screen, and the staleness caveat when the entry predates
3448
+ // the layout that mapped it. Empty when there is nothing to add.
3449
+ function refTitle(run) {
3450
+ const parts = [];
3451
+ if (run.shown !== run.raw)
3452
+ parts.push(run.raw);
3453
+ if (run.stale)
3454
+ parts.push(STALE_REF_NOTE);
3455
+ return parts.join('\n');
3456
+ }
3457
+ function resolveLogMessageRuns(message, timestamp) {
3458
+ return parseStackRefs(message).map((run) => {
3459
+ if (run.kind === 'text') {
3460
+ return { shown: run.text, raw: run.text, tabIndex: -1, stale: false };
3461
+ }
3462
+ // One `raw` and one `tabIndex` for every outcome, so that "every
3463
+ // rendered ref goes through the decode" is structural rather than a
3464
+ // property of remembering to call it on each branch.
3465
+ const resolved = resolveLogRef(run, timestamp);
3466
+ const ref = resolved.kind === 'mapped' ? resolved.ref : run;
3467
+ const raw = resolved.kind === 'mapped'
3468
+ ? `${ref.url}:${ref.line}:${ref.col}`
3469
+ : run.text;
3470
+ // An unmappable ref keeps its text but must never become a link,
3471
+ // even though its URL is this script's and so does match a tab —
3472
+ // the generated line indexes the concatenated program.
3473
+ const tabIndex = resolved.kind === 'unmappable'
3474
+ ? -1
3475
+ : tabsView.findTabBySourceUrl(ref.url);
3476
+ return {
3477
+ shown: displayRefText(raw, ref.url),
3478
+ raw,
3479
+ ref,
3480
+ tabIndex,
3481
+ stale: resolved.kind === 'mapped' && resolved.stale,
3482
+ };
3483
+ });
3484
+ }
3485
+ // A log entry's message as plain text, exactly as the table renders it
3486
+ // — the same remapped, decoded refs, minus the links. This is what the
3487
+ // CSV export carries; see `ResolvedRun`.
3488
+ function logMessageText(entry) {
3489
+ // An entry carrying a derived location has no refs to scan (the
3490
+ // message is our own prose), and the table appends the location with
3491
+ // the shared phrasing. `formatLogMessage` is that phrasing.
3492
+ if (entry.location)
3493
+ return formatLogMessage(entry);
3494
+ return resolveLogMessageRuns(entry.message, entry.timestamp)
3495
+ .map((run) => run.shown)
3496
+ .join('');
3497
+ }
3498
+ // Paint a log entry's message into a <td>. Each URL:line:col stack
3499
+ // ref is first mapped back to the file it was written in
3500
+ // (`remapLogRef`); if the result matches a code tab's `sourceUrl` it
3501
+ // becomes a clickable link that switches to that tab and jumps to
3502
+ // line/col. Refs that don't match any tab render as plain text — they
3503
+ // may point at code outside this script (the page, another userscript,
3504
+ // a browser internal).
3505
+ //
3506
+ // A remapped ref is re-rendered with its new URL and line, so the text
3507
+ // the user reads names the real file. That is the whole point: the
3508
+ // generated line number is an artifact of our injection and means
3509
+ // nothing to someone reading their own script.
3510
+ function appendLogMessageContent(td, message, timestamp) {
3511
+ const runs = resolveLogMessageRuns(message, timestamp);
3512
+ if (runs.length === 1 && runs[0].ref === undefined) {
3513
+ td.textContent = message;
3514
+ return;
3515
+ }
3516
+ td.replaceChildren();
3517
+ for (const run of runs) {
3518
+ if (!run.ref) {
3519
+ td.appendChild(document.createTextNode(run.shown));
3520
+ continue;
3521
+ }
3522
+ if (run.tabIndex < 0) {
3523
+ td.appendChild(refNode(run));
3524
+ continue;
3525
+ }
3526
+ const { ref, tabIndex } = run;
3527
+ const a = document.createElement('a');
3528
+ a.className = 'log-stack-link';
3529
+ a.href = '#';
3530
+ setRefText(a, run);
3531
+ a.addEventListener('click', (ev) => {
3532
+ ev.preventDefault();
3533
+ tabsView.goToTabLine(tabIndex, ref.line, ref.col);
3534
+ });
3535
+ td.appendChild(a);
3536
+ }
3537
+ }
3538
+ async function onSaveLog(entries) {
3539
+ // The click-time snapshot, deliberately: the dialog can sit open
3540
+ // while new entries land, and the file the user asked for is the log
3541
+ // they were looking at when they asked.
3542
+ const csv = formatLogCsv(entries, logMessageText);
3543
+ const filename = logFileName(currentLogId);
3544
+ // Read before anything is awaited, and called in the same turn, so
3545
+ // the click's user activation is still live — the picker refuses to
3546
+ // open without it.
3547
+ const picker = window.showSaveFilePicker;
3548
+ let handle = null;
3549
+ if (picker) {
3550
+ try {
3551
+ handle = await picker({ suggestedName: filename });
3552
+ }
3553
+ catch (err) {
3554
+ // Closing the dialog is a decision, not a failure — falling back
3555
+ // to a download would write the file they just declined.
3556
+ if (err instanceof DOMException && err.name === 'AbortError')
3557
+ return;
3558
+ // Anything else means the API is there but wouldn't open here.
3559
+ // The download below still gets the log onto disk.
3560
+ }
3561
+ }
3562
+ if (!handle) {
3563
+ // No dialog: a plain download, which the browser announces itself.
3564
+ downloadLogFile(csv, filename);
3565
+ return;
3566
+ }
3567
+ try {
3568
+ const writable = await handle.createWritable();
3569
+ await writable.write(csv);
3570
+ await writable.close();
3571
+ reportLogAction('save', 'Saved');
3572
+ }
3573
+ catch {
3574
+ // The user already chose a destination, so a download into their
3575
+ // downloads folder would be a second file they never asked for —
3576
+ // and would read as the save having worked. Report instead.
3577
+ reportLogAction('save', 'Save failed');
3578
+ }
3579
+ }
3580
+ // Fallback path: a plain download, which lands in the browser's
3581
+ // download folder under `filename` with no dialog. Used when the
3582
+ // picker isn't available.
3583
+ function downloadLogFile(csv, filename) {
3584
+ const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
3585
+ const a = document.createElement('a');
3586
+ a.href = url;
3587
+ a.download = filename;
3588
+ a.click();
3589
+ // Chrome reads the blob after the click returns, so revoking inline
3590
+ // can cancel the download. The delay is a courtesy to the page's
3591
+ // memory, not a correctness requirement — the URL dies with the page
3592
+ // either way.
3593
+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
3594
+ }
3595
+ const LOG_ACTION_LABELS = { save: 'Save', copy: 'Copy' };
3596
+ // How long a reported outcome stays on the button.
3597
+ const LOG_ACTION_FEEDBACK_MS = 1200;
3598
+ const logActionFeedback = new Map();
3599
+ const logActionButtons = new Map();
3600
+ const logActionTimers = new Map();
3601
+ function logActionLabel(action) {
3602
+ return logActionFeedback.get(action) ?? LOG_ACTION_LABELS[action];
3603
+ }
3604
+ // Say what just happened, then fall back to the idle label. `label` of
3605
+ // null resets immediately.
3606
+ function reportLogAction(action, label) {
3607
+ if (label === null)
3608
+ logActionFeedback.delete(action);
3609
+ else
3610
+ logActionFeedback.set(action, label);
3611
+ const btn = logActionButtons.get(action);
3612
+ if (btn)
3613
+ btn.textContent = logActionLabel(action);
3614
+ // A fresh timer per report, so rapid clicks reschedule rather than
3615
+ // letting an early one cut the latest feedback short.
3616
+ const pending = logActionTimers.get(action);
3617
+ if (pending !== undefined)
3618
+ clearTimeout(pending);
3619
+ logActionTimers.delete(action);
3620
+ if (label === null)
3621
+ return;
3622
+ logActionTimers.set(action, setTimeout(() => {
3623
+ logActionTimers.delete(action);
3624
+ reportLogAction(action, null);
3625
+ }, LOG_ACTION_FEEDBACK_MS));
3626
+ }
3627
+ // One toolbar button, wearing whatever its action is currently
3628
+ // reporting.
3629
+ function logActionButton(action, title, onClick) {
3630
+ const btn = document.createElement('button');
3631
+ btn.type = 'button';
3632
+ btn.textContent = logActionLabel(action);
3633
+ btn.title = title;
3634
+ // The relabel IS the feedback, and a button's name is not announced
3635
+ // again on its own — so mark it live for a screen reader.
3636
+ btn.setAttribute('aria-live', 'polite');
3637
+ btn.addEventListener('click', onClick);
3638
+ logActionButtons.set(action, btn);
3639
+ return btn;
3640
+ }
3641
+ async function onCopyLog(entries) {
3642
+ let label = 'Copied';
3643
+ try {
3644
+ await navigator.clipboard.writeText(formatLogCsv(entries, logMessageText));
3645
+ }
3646
+ catch {
3647
+ // The clipboard write can be refused (no permission, page not
3648
+ // focused). Say so on the button — unlike the Location row, the
3649
+ // log is not selectable text the user can fall back to copying by
3650
+ // hand, so silence would read as a copy that worked.
3651
+ label = 'Copy failed';
3652
+ }
3653
+ reportLogAction('copy', label);
3654
+ }
3655
+ async function onClearLog() {
3656
+ if (!scriptId)
3657
+ return;
3658
+ await clearScriptLog(scriptId);
3659
+ // Drop any outcome Save/Copy are still reporting: it is about a log
3660
+ // that no longer exists, and the repaint below disables those
3661
+ // buttons — a greyed-out "Copied" reads as a broken control.
3662
+ reportLogAction('save', null);
3663
+ reportLogAction('copy', null);
3664
+ // Paint the empty state ourselves rather than waiting for the
3665
+ // storage.onChanged listener, which fires shortly with the same empty
3666
+ // state — so the second paint is a visual no-op. Not instant: the
3667
+ // clear above is a message hop to the worker (which owns the log's
3668
+ // writes — see script-log.ts) and may have to wake it. Bump the epoch
3669
+ // so any in-flight renderLogPane() read can't undo this.
3670
+ logPaintEpoch++;
3671
+ paintLog([]);
3672
+ }