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,619 @@
1
+ // The create / edit collection dialog, shared by the collections page
2
+ // (Add field, Edit button) and the install page ("New collection…" in
3
+ // the Install-into dropdown). One `<dialog>` per page, built on first
4
+ // use; `openCollectionDialog` resolves when it closes.
5
+ //
6
+ // The dialog owns the whole decision: it validates live as the user
7
+ // types (name required / in use, what the location turns out to be),
8
+ // probes a location before creating from it, and shows every failure in
9
+ // its own error slot — so a location that can't be read never lands as
10
+ // a collection stuck in a scan error, and the caller only ever hears
11
+ // about a collection that exists.
12
+ //
13
+ // The type is never asked for, only reported: a Type row shows the
14
+ // collection bubble the form would produce — `scripts` with no
15
+ // location, `<scheme>-dir` / `<scheme>-manifest` for a location we
16
+ // could classify, and `invalid` for anything else.
17
+ //
18
+ // See docs/storage.md#the-collection-dialog.
19
+ import { COLLECTION_ALREADY_REGISTERED, COLLECTION_GONE, COLLECTION_NAME_REQUIRED, collectionNameInUseMessage, collectionUsesLocalFiles, describeError, getCollections, probeCollectionLocation, } from './script-store.js';
20
+ import { createCollection, setCollectionOptions } from './worker-client.js';
21
+ import { canonicalCollectionInput, classifyLocation, collectionKind, SINGLE_SCRIPT_NOT_A_COLLECTION, } from './directories.js';
22
+ import { collectionBubbleKey, inferCollectionType, inferDisplayName, mayUseReservedName, RESERVED_COLLECTION_NAME, SCRIPTS_BUBBLE_KEY, collectionNameInUse, } from './collection-display.js';
23
+ import { MANIFEST_FILENAME } from '../collection-scan.js';
24
+ import { collectionSupportsLocalFiles } from '../local-file-overrides.js';
25
+ import { dlog } from '../debug-log.js';
26
+ import { previewUrl } from '../script-display.js';
27
+ import { openExtensionTab } from './page-links.js';
28
+ import { setGlyphButton, setGlyphButtonLabel } from './glyph.js';
29
+ import { makeCopyButton } from './copy-button.js';
30
+ import { isFileUrl, isHttpUrl, isManifestCollection, pathToFileUrl } from '../file-locations.js';
31
+ const CREATE_WORDING = {
32
+ collectionOnly: { title: 'New collection', ok: 'Create' },
33
+ install: { title: 'Create new collection and install script', ok: 'Create and install' },
34
+ move: { title: 'Create new collection and move script', ok: 'Create and move' },
35
+ };
36
+ // The hint under the local-overrides option, by why it does or doesn't
37
+ // apply. The option is shown for every collection so the dialog's
38
+ // shape doesn't shift with the type; where it can't act, the hint says
39
+ // why instead of leaving a greyed box to wonder about.
40
+ const LOCAL_FILES_HINT = 'Load local versions of @require and @resource files found in the ' +
41
+ 'script or collection directories, or in the global search path';
42
+ function localFilesUnavailableHint(location) {
43
+ const where = inferCollectionType(location) === 'github' ? 'from GitHub' : 'over http';
44
+ return `Not applicable for scripts loaded ${where}`;
45
+ }
46
+ // Shown once when a location reads fine but holds no scripts. A
47
+ // warning, not a refusal: a directory can fill up later, so the next
48
+ // Create goes through.
49
+ const EMPTY_LOCATION_WARNING = 'No .user.js files found. Click Create to add this collection anyway.';
50
+ // Bubble keys that are states of the form rather than collection kinds.
51
+ // Both get their own colour in styles.css.
52
+ const INVALID_BUBBLE_KEY = 'invalid';
53
+ const CHECKING_BUBBLE_KEY = 'checking';
54
+ // What each Type bubble means, as its tooltip. The radios this row
55
+ // replaced carried a one-line gloss each; without something here a
56
+ // first-time reader gets only an uppercase token.
57
+ const TYPE_BUBBLE_TITLES = {
58
+ [SCRIPTS_BUBBLE_KEY]: 'Scripts installed one at a time, from anywhere',
59
+ [INVALID_BUBBLE_KEY]: 'Invalid collection specification',
60
+ [CHECKING_BUBBLE_KEY]: 'Reading this location…',
61
+ };
62
+ function typeBubbleTitle(bubbleKey) {
63
+ const known = TYPE_BUBBLE_TITLES[bubbleKey];
64
+ if (known)
65
+ return known;
66
+ if (bubbleKey.endsWith('-manifest'))
67
+ return `Every script listed in this ${MANIFEST_FILENAME}`;
68
+ return bubbleKey.startsWith('github')
69
+ ? 'Every .user.js in this GitHub folder'
70
+ : 'Every .user.js under this directory';
71
+ }
72
+ // What the Type row says beside `INVALID` on a dialog nothing has been
73
+ // typed into yet — the one state where the error slot stays empty,
74
+ // because nothing is wrong yet, only unfinished. Naming only what this
75
+ // dialog can actually take: a scripts-only create has no location
76
+ // field to point at.
77
+ function enterSomethingHint(showLocation) {
78
+ return showLocation ? 'Enter a name or path' : 'Enter a name';
79
+ }
80
+ // How long the location field sits still before its verdict is
81
+ // fetched. A local classification reads the disk, so it is not run per
82
+ // keystroke.
83
+ const LOCATION_PROBE_DELAY_MS = 250;
84
+ let elements = null;
85
+ // One dialog at a time, claimed SYNCHRONOUSLY. `dialog.open` can't be
86
+ // the guard: `runDialog` reads the collection list before it calls
87
+ // `showModal`, so during that await a second call would see a closed
88
+ // dialog, pass, and then overwrite every handler the first run
89
+ // installed — leaving the first caller's promise unsettled forever and
90
+ // the second's rejected by `showModal` on an already-open dialog.
91
+ // Reachable by double-clicking Edit.
92
+ let dialogInFlight = false;
93
+ function el(tag, className, text) {
94
+ const node = document.createElement(tag);
95
+ if (className)
96
+ node.className = className;
97
+ if (text !== undefined)
98
+ node.textContent = text;
99
+ return node;
100
+ }
101
+ function buildDialog() {
102
+ const dialog = el('dialog', 'sm-dialog collection-dialog');
103
+ const title = el('h2', 'dlg-title');
104
+ dialog.appendChild(title);
105
+ const form = el('form', 'dlg-body');
106
+ form.noValidate = true;
107
+ dialog.appendChild(form);
108
+ const nameLabel = el('label', 'dlg-label', 'Name');
109
+ nameLabel.htmlFor = 'collection-dialog-name';
110
+ const name = el('input');
111
+ name.type = 'text';
112
+ name.id = 'collection-dialog-name';
113
+ name.autocomplete = 'off';
114
+ form.append(nameLabel, name);
115
+ const locationLabel = el('label', 'dlg-label dlg-location-label', 'Directory or manifest');
116
+ locationLabel.htmlFor = 'collection-dialog-location';
117
+ const location = el('input');
118
+ location.type = 'text';
119
+ location.id = 'collection-dialog-location';
120
+ location.autocomplete = 'off';
121
+ location.spellcheck = false;
122
+ // Short, because the label beside it already says what kind of
123
+ // directory or manifest is wanted.
124
+ location.placeholder = 'Absolute path or URL';
125
+ // Opens what the box names in a new tab: its directory listing,
126
+ // manifest or GitHub page. Enabled while the box holds something a
127
+ // link can be made from (`locationLink`).
128
+ const openLocation = el('button', 'dlg-open-location');
129
+ openLocation.type = 'button';
130
+ // Retitled per value (`locationNoun`).
131
+ setGlyphButton(openLocation, '#sm-open', 'Open in a new tab');
132
+ // Copies what the box names: a local location as its plain path (a
133
+ // `file://` URL loses the prefix), anything else as given.
134
+ const copyLocation = makeCopyButton({
135
+ label: 'Copy',
136
+ text: () => canonicalCollectionInput(location.value.trim()),
137
+ });
138
+ const locationRow = el('div', 'dlg-location');
139
+ locationRow.append(location, copyLocation, openLocation);
140
+ form.append(locationLabel, locationRow);
141
+ form.appendChild(el('span', 'dlg-label', 'Type'));
142
+ const typeRow = el('div', 'dlg-type');
143
+ const typeBubble = el('span', 'collection-type-bubble');
144
+ const typeHint = el('span', 'dlg-hint');
145
+ typeRow.append(typeBubble, typeHint);
146
+ form.appendChild(typeRow);
147
+ form.appendChild(el('span', 'dlg-label', 'Options'));
148
+ const localFilesLabel = el('label', 'dlg-check');
149
+ const localFiles = el('input');
150
+ localFiles.type = 'checkbox';
151
+ localFiles.id = 'collection-dialog-local-files';
152
+ const localText = el('span');
153
+ localText.appendChild(document.createTextNode('Allow local file overrides'));
154
+ const localFilesHint = el('span', 'dlg-hint');
155
+ localText.appendChild(localFilesHint);
156
+ localFilesLabel.append(localFiles, localText);
157
+ form.appendChild(localFilesLabel);
158
+ // The message and its optional action sit side by side in one row, so
159
+ // the offer and the reason for it read as one thing. The button is a
160
+ // SIBLING of the message rather than a child: `.dlg-error` is what
161
+ // every caller (and every test) reads the message from, and a nested
162
+ // button would show up in its text even while hidden.
163
+ const errorRow = el('div', 'dlg-error-row');
164
+ const error = el('p', 'dlg-error');
165
+ error.setAttribute('role', 'alert');
166
+ // Only the single-script verdict raises it.
167
+ const errorAction = el('button', 'dlg-error-action', 'Install script');
168
+ errorAction.type = 'button';
169
+ errorAction.hidden = true;
170
+ errorRow.append(error, errorAction);
171
+ dialog.appendChild(errorRow);
172
+ const actions = el('div', 'dlg-actions');
173
+ const cancel = el('button', undefined, 'Cancel');
174
+ cancel.type = 'button';
175
+ const ok = el('button', 'primary');
176
+ ok.type = 'submit';
177
+ ok.setAttribute('form', 'collection-dialog-form');
178
+ form.id = 'collection-dialog-form';
179
+ actions.append(cancel, ok);
180
+ dialog.appendChild(actions);
181
+ document.body.appendChild(dialog);
182
+ return {
183
+ dialog, form, title, locationLabel, locationRow, location, copyLocation, openLocation, name, typeBubble, typeHint,
184
+ localFilesLabel, localFiles, localFilesHint, error, errorAction, cancel, ok,
185
+ };
186
+ }
187
+ // Open the dialog and resolve with what it made or changed, or null
188
+ // when it was dismissed. One at a time: a second call while one is
189
+ // open resolves null immediately.
190
+ export function openCollectionDialog(request) {
191
+ if (!elements)
192
+ elements = buildDialog();
193
+ const ui = elements;
194
+ if (dialogInFlight)
195
+ return Promise.resolve(null);
196
+ dialogInFlight = true;
197
+ return new Promise((resolve) => {
198
+ void runDialog(ui, request, (outcome) => {
199
+ dialogInFlight = false;
200
+ resolve(outcome);
201
+ }).catch((err) => {
202
+ // Nothing in `runDialog` before `showModal` is expected to throw,
203
+ // but a rejection there would otherwise strand the caller AND
204
+ // leave the flag set, wedging every later open.
205
+ dialogInFlight = false;
206
+ resolve(null);
207
+ dlog(`[SourceMonkey] collection dialog failed to open: ${describeError(err)}`);
208
+ });
209
+ });
210
+ }
211
+ async function runDialog(ui, request, resolve) {
212
+ const { mode, collection } = request;
213
+ const editing = mode === 'edit' ? collection ?? null : null;
214
+ // Everything else on the page, for the live "already exists" checks.
215
+ // A snapshot: a collection added from another tab while this dialog
216
+ // is open is caught by the store's own check on submit.
217
+ const others = (await getCollections()).filter((c) => c.id !== editing?.id);
218
+ // ---- state ----
219
+ // A location is off the table for a scripts-only create, and fixed
220
+ // (shown read-only, or absent) when editing.
221
+ const editingScripts = !!editing && collectionKind(editing.path) === 'scripts';
222
+ const showLocation = !request.scriptsOnly && !editingScripts;
223
+ // The empty-directory warning blocks once; the second Create goes
224
+ // through.
225
+ let warnedEmpty = false;
226
+ let settled = false;
227
+ let probeTimer;
228
+ // The opening paint. Its location came from the Add field, not from
229
+ // typing, so it is classified straight away rather than after the
230
+ // keystroke debounce.
231
+ let firstPaint = true;
232
+ const finish = (outcome) => {
233
+ if (settled)
234
+ return;
235
+ settled = true;
236
+ // A pending classification has nothing left to update.
237
+ if (probeTimer !== undefined)
238
+ clearTimeout(probeTimer);
239
+ if (ui.dialog.open)
240
+ ui.dialog.close();
241
+ resolve(outcome);
242
+ };
243
+ // ---- fill ----
244
+ const wording = CREATE_WORDING[request.createPurpose ?? 'collectionOnly'];
245
+ ui.title.textContent = editing ? 'Edit collection' : wording.title;
246
+ ui.ok.textContent = editing ? 'Save' : wording.ok;
247
+ ui.location.value = editing ? locationFieldValue(editing.path) : request.prefill?.location ?? '';
248
+ ui.location.disabled = !!editing;
249
+ // Editing, the location is fixed, so its label names the one it is.
250
+ // Adding, it offers both, over two lines to keep the column narrow.
251
+ if (!editing)
252
+ ui.locationLabel.replaceChildren('Directory or ', el('br'), 'manifest');
253
+ else
254
+ ui.locationLabel.textContent = collectionKind(editing.path) === 'manifest' ? 'Manifest' : 'Directory';
255
+ ui.locationLabel.hidden = !showLocation;
256
+ ui.location.hidden = !showLocation;
257
+ ui.locationRow.hidden = !showLocation;
258
+ // Blank for a new location collection: the inferred name is what a
259
+ // blank means, and echoing the path into the field would only invite
260
+ // editing a value that isn't the user's.
261
+ ui.name.value = editing
262
+ ? editing.displayName?.trim() || inferDisplayName(editing.path)
263
+ : request.prefill?.name ?? '';
264
+ ui.localFiles.checked = editing ? collectionUsesLocalFiles(editing) : true;
265
+ const setError = (text, warn = false) => {
266
+ ui.error.textContent = text;
267
+ ui.error.classList.toggle('warn', warn);
268
+ };
269
+ // The typed location in the one spelling the store will hold, so far
270
+ // as can be told without a network call.
271
+ const canonicalLoc = () => canonicalCollectionInput(ui.location.value.trim());
272
+ // Verdicts already reached, keyed by the exact string typed, plus the
273
+ // debounce that fetches the next one. Declared above `evaluate`,
274
+ // which calls it: a `const` arrow used before its line is a TDZ trap
275
+ // for whoever adds the next caller.
276
+ const verdicts = new Map();
277
+ let probingFor = null;
278
+ // The verdict for `loc`, or null while we are still finding out —
279
+ // in which case a probe is scheduled and `refresh` runs again when it
280
+ // lands.
281
+ //
282
+ // `now` skips the debounce for a location the user didn't type here
283
+ // (an Add-field prefill), which is already complete and would
284
+ // otherwise show `checking` for a quarter second on every add.
285
+ const verdictFor = (loc, now = false) => {
286
+ const known = verdicts.get(loc);
287
+ if (known)
288
+ return known;
289
+ if (probingFor !== loc) {
290
+ probingFor = loc;
291
+ if (probeTimer !== undefined)
292
+ clearTimeout(probeTimer);
293
+ const run = () => {
294
+ void classifyLocation(loc)
295
+ // A classification that threw is still a classification —
296
+ // whatever went wrong is what's wrong with this location.
297
+ .catch((err) => ({ kind: 'invalid', message: describeError(err) }))
298
+ .then((verdict) => {
299
+ verdicts.set(loc, verdict);
300
+ if (!settled)
301
+ refresh();
302
+ });
303
+ };
304
+ if (now)
305
+ run();
306
+ else
307
+ probeTimer = setTimeout(run, LOCATION_PROBE_DELAY_MS);
308
+ }
309
+ return null;
310
+ };
311
+ // Every edit forgets the refusals. A location that couldn't be read
312
+ // may become readable while the dialog is open — the user creates the
313
+ // directory, or turns file access on — and a cached refusal would
314
+ // outlast the fix, so retyping the path is the retry. Only refusals:
315
+ // re-reading a directory we already listed would just cost a scan.
316
+ const forgetRefusals = () => {
317
+ for (const [loc, verdict] of verdicts) {
318
+ if (verdict.kind === 'invalid')
319
+ verdicts.delete(loc);
320
+ }
321
+ probingFor = null;
322
+ };
323
+ // What's wrong with the typed name, or null. Blank is never wrong
324
+ // here: for a location collection it means "use the inferred name",
325
+ // and for a scripts collection the caller checks it separately (the
326
+ // Type row says what a nameless, locationless form still needs).
327
+ const nameProblem = (inferred) => {
328
+ const name = ui.name.value.trim();
329
+ if (!name)
330
+ return null;
331
+ // The row's own path is what earns an exemption from the reserved
332
+ // names — a built-in default keeps the name it owns.
333
+ if (!mayUseReservedName(name, editing ? editing.path : null))
334
+ return RESERVED_COLLECTION_NAME;
335
+ // Only a name the user chose is held unique — the same test
336
+ // `createLocationCollection` runs, against the same canonical
337
+ // spelling, so the two can't disagree about whether a typed name
338
+ // counts as chosen. (A github URL the store would still rewrite via
339
+ // its API is the one case they can: that rewrite needs a network
340
+ // round trip, so the store's own check stays the backstop.)
341
+ if (name !== inferred && collectionNameInUse(name, others)) {
342
+ return collectionNameInUseMessage(name);
343
+ }
344
+ return null;
345
+ };
346
+ // Runs on every keystroke; Create/Save is disabled while it says so.
347
+ const evaluate = () => {
348
+ const blank = { hint: '', message: '', field: null, ready: true, scriptLocation: '' };
349
+ if (editing) {
350
+ // Type and location are fixed, so only the name can be wrong.
351
+ // A blank name means "use the inferred one" — but a scripts
352
+ // collection has no location to infer from, so for that one blank
353
+ // is simply missing, exactly as it is when creating.
354
+ const inferred = inferDisplayName(editing.path);
355
+ const message = nameProblem(inferred);
356
+ const missing = !message && !inferred && !ui.name.value.trim();
357
+ return {
358
+ ...blank,
359
+ bubble: collectionBubbleKey(editing.path),
360
+ hint: missing ? enterSomethingHint(false) : '',
361
+ message: message ?? '',
362
+ field: message ? 'name' : null,
363
+ ready: !message && !missing,
364
+ };
365
+ }
366
+ const loc = showLocation ? ui.location.value.trim() : '';
367
+ const name = ui.name.value.trim();
368
+ if (!loc) {
369
+ // No location: a collection of individually installed scripts,
370
+ // which is nothing at all until it is named.
371
+ if (!name) {
372
+ return { ...blank, bubble: INVALID_BUBBLE_KEY, hint: enterSomethingHint(showLocation), ready: false };
373
+ }
374
+ const message = nameProblem('');
375
+ return {
376
+ ...blank,
377
+ bubble: SCRIPTS_BUBBLE_KEY,
378
+ message: message ?? '',
379
+ field: message ? 'name' : null,
380
+ ready: !message,
381
+ };
382
+ }
383
+ const verdict = verdictFor(loc, firstPaint);
384
+ if (!verdict)
385
+ return { ...blank, bubble: CHECKING_BUBBLE_KEY, ready: false };
386
+ if (verdict.kind === 'script') {
387
+ return {
388
+ ...blank,
389
+ bubble: INVALID_BUBBLE_KEY,
390
+ message: SINGLE_SCRIPT_NOT_A_COLLECTION,
391
+ field: 'location',
392
+ ready: false,
393
+ scriptLocation: loc,
394
+ };
395
+ }
396
+ if (verdict.kind === 'invalid') {
397
+ return { ...blank, bubble: INVALID_BUBBLE_KEY, message: verdict.message, field: 'location', ready: false };
398
+ }
399
+ // The same key `collectionBubbleKey` builds for a stored row, but
400
+ // from the verdict rather than the path: the two agree on the
401
+ // scheme half and can differ on the other, because this one knows
402
+ // what was actually read where that one only has a filename.
403
+ const bubble = `${inferCollectionType(loc)}-${verdict.kind === 'manifest' ? 'manifest' : 'dir'}`;
404
+ if (others.some((c) => canonicalCollectionInput(c.path) === canonicalLoc())) {
405
+ return { ...blank, bubble, message: COLLECTION_ALREADY_REGISTERED, field: 'location', ready: false };
406
+ }
407
+ // Name problems are reported only once the location is sound: the
408
+ // location is the field being filled in, and marking Name while the
409
+ // location is unusable points at the wrong one.
410
+ const message = nameProblem(inferDisplayName(canonicalLoc()));
411
+ return {
412
+ ...blank,
413
+ bubble,
414
+ message: message ?? '',
415
+ field: message ? 'name' : null,
416
+ ready: !message,
417
+ };
418
+ };
419
+ const refresh = () => {
420
+ const state = evaluate();
421
+ const loc = showLocation ? ui.location.value.trim() : '';
422
+ const supportsLocal = !loc || collectionSupportsLocalFiles(loc);
423
+ ui.localFiles.disabled = !supportsLocal;
424
+ ui.localFilesLabel.classList.toggle('off', !supportsLocal);
425
+ ui.localFilesHint.textContent = supportsLocal ? `: ${LOCAL_FILES_HINT}` : `: ${localFilesUnavailableHint(loc)}`;
426
+ // The placeholder is the field's own explanation of what blank
427
+ // means — required for a scripts collection, "use the inferred
428
+ // name" once a location says what the collection is.
429
+ ui.name.placeholder = !showLocation || !loc
430
+ ? COLLECTION_NAME_REQUIRED
431
+ : namePlaceholderFor(state.bubble);
432
+ ui.typeBubble.className = `collection-type-bubble bubble-${state.bubble}`;
433
+ ui.typeBubble.textContent = state.bubble;
434
+ ui.typeBubble.title = typeBubbleTitle(state.bubble);
435
+ ui.typeHint.textContent = state.hint;
436
+ ui.location.classList.toggle('invalid', state.field === 'location');
437
+ ui.name.classList.toggle('invalid', state.field === 'name');
438
+ setError(state.message);
439
+ ui.errorAction.hidden = !state.scriptLocation;
440
+ ui.errorAction.onclick = state.scriptLocation
441
+ // A new tab: the dialog's caller (usually the collections page)
442
+ // stays where it was, so backing out of the install lands the
443
+ // user where they started.
444
+ ? () => { void openExtensionTab(chrome.runtime.getURL(previewUrl(state.scriptLocation))); }
445
+ : null;
446
+ ui.ok.disabled = !state.ready;
447
+ };
448
+ // ---- wiring (replaced on every open, so stale closures can't fire) ----
449
+ const syncOpenLocation = () => {
450
+ ui.openLocation.disabled = locationLink(ui.location.value) === '';
451
+ ui.copyLocation.disabled = ui.location.value.trim() === '';
452
+ // Named by what the box holds: "Copy filename", "Open URL in a new tab".
453
+ const noun = locationNoun(ui.location.value);
454
+ setGlyphButtonLabel(ui.openLocation, noun ? `Open ${noun.open} in a new tab` : 'Open in a new tab');
455
+ const copyLabel = noun ? `Copy ${noun.copy}` : 'Copy';
456
+ ui.copyLocation.title = copyLabel;
457
+ ui.copyLocation.setAttribute('aria-label', copyLabel);
458
+ };
459
+ syncOpenLocation();
460
+ ui.openLocation.onclick = () => {
461
+ const link = locationLink(ui.location.value);
462
+ if (!link)
463
+ return;
464
+ openExtensionTab(link).catch((err) => {
465
+ dlog(`[SourceMonkey] could not open ${link}: ${describeError(err)}`);
466
+ });
467
+ };
468
+ ui.location.oninput = () => {
469
+ warnedEmpty = false;
470
+ forgetRefusals();
471
+ syncOpenLocation();
472
+ refresh();
473
+ };
474
+ ui.name.oninput = () => refresh();
475
+ ui.localFiles.onchange = () => refresh();
476
+ ui.cancel.onclick = () => finish(null);
477
+ ui.dialog.onclose = () => finish(null);
478
+ ui.form.onsubmit = (event) => {
479
+ event.preventDefault();
480
+ void submit();
481
+ };
482
+ const busy = async (work) => {
483
+ ui.ok.disabled = true;
484
+ ui.cancel.disabled = true;
485
+ const spinner = el('span', 'busy-spinner');
486
+ ui.ok.after(spinner);
487
+ try {
488
+ return await work();
489
+ }
490
+ finally {
491
+ spinner.remove();
492
+ ui.cancel.disabled = false;
493
+ // Live again even when the slot now shows a failure, deliberately:
494
+ // the empty-directory warning is a "click Create again" prompt,
495
+ // and a probe that failed on a network blip deserves a retry
496
+ // without retyping. So the "disabled while `evaluate` says so"
497
+ // invariant holds only up to the first submit.
498
+ ui.ok.disabled = false;
499
+ }
500
+ };
501
+ const submit = async () => {
502
+ refresh();
503
+ if (ui.ok.disabled)
504
+ return;
505
+ const name = ui.name.value.trim();
506
+ const localFiles = ui.localFiles.disabled ? undefined : ui.localFiles.checked;
507
+ const loc = showLocation ? ui.location.value.trim() : '';
508
+ try {
509
+ if (editing) {
510
+ const priorLocal = collectionUsesLocalFiles(editing);
511
+ const updated = await busy(() => setCollectionOptions(editing.id, {
512
+ displayName: name,
513
+ ...(localFiles === undefined ? {} : { localFiles }),
514
+ }));
515
+ if (!updated) {
516
+ setError(COLLECTION_GONE_HERE);
517
+ return;
518
+ }
519
+ finish({
520
+ collection: updated,
521
+ created: false,
522
+ localFilesChanged: localFiles !== undefined && localFiles !== priorLocal,
523
+ });
524
+ return;
525
+ }
526
+ if (!loc) {
527
+ // Only a location collection can come back null (a duplicate
528
+ // location); the scripts branch throws on the one thing it
529
+ // refuses, a name already in use. A null here would be a store
530
+ // bug, so it says so rather than guessing at a cause.
531
+ const result = await busy(() => createCollection({ kind: 'scripts', name, localFiles }));
532
+ if (!result) {
533
+ setError('Internal error: the collection was not created');
534
+ return;
535
+ }
536
+ finish({ collection: result.collection, created: true, localFilesChanged: false });
537
+ return;
538
+ }
539
+ // Read before writing: a location that can't be listed is
540
+ // refused here, with the reason, rather than becoming a section
541
+ // whose only content is a scan error. Empty is allowed, but said
542
+ // once first.
543
+ const probe = await busy(() => probeCollectionLocation(loc));
544
+ if (probe.scriptCount === 0 && !warnedEmpty) {
545
+ warnedEmpty = true;
546
+ setError(EMPTY_LOCATION_WARNING, true);
547
+ return;
548
+ }
549
+ const result = await busy(() => createCollection({ kind: 'location', path: loc, name, localFiles }));
550
+ if (!result) {
551
+ setError(COLLECTION_ALREADY_REGISTERED);
552
+ return;
553
+ }
554
+ finish({ collection: result.collection, created: true, localFilesChanged: false });
555
+ }
556
+ catch (err) {
557
+ setError(describeError(err));
558
+ // A create that failed on the location — the probe, or the store's
559
+ // own dedup — is about that field.
560
+ if (!editing && loc)
561
+ ui.location.classList.add('invalid');
562
+ }
563
+ };
564
+ refresh();
565
+ firstPaint = false;
566
+ ui.dialog.showModal();
567
+ // Where typing starts: the location for a new collection that has
568
+ // none yet, the name otherwise.
569
+ if (showLocation && !editing && !ui.location.value)
570
+ ui.location.focus();
571
+ else
572
+ ui.name.focus();
573
+ }
574
+ // Edit-mode message for a row that vanished under the dialog: the
575
+ // shared string, wrapped with what to do about it here.
576
+ const COLLECTION_GONE_HERE = `${COLLECTION_GONE}. Close this dialog and check the page.`;
577
+ // What a blank Name means, once a location has said what kind of
578
+ // collection this is. The `checking` / `invalid` states have no kind to
579
+ // name yet, so they get the bare form.
580
+ function namePlaceholderFor(bubbleKey) {
581
+ const kind = bubbleKey.endsWith('-manifest') ? 'manifest'
582
+ : bubbleKey.endsWith('-dir') ? 'directory'
583
+ : null;
584
+ return kind ? `Name is optional for ${kind} collections` : 'Name is optional';
585
+ }
586
+ // What the (read-only) Location field shows for an existing row: the
587
+ // stored path, which is already the canonical spelling. A container
588
+ // has none. Not `displayLocation` — `script-display.ts` exports that
589
+ // name for a different job (a script's own location, decoded).
590
+ function locationFieldValue(path) {
591
+ return collectionKind(path) === 'scripts' ? '' : path;
592
+ }
593
+ // What the location box names, as a link a tab can open: an http(s) or
594
+ // file:// URL as given, an absolute path as a file:// URL. '' for
595
+ // anything else, which disables the button.
596
+ function locationLink(value) {
597
+ const v = value.trim();
598
+ if (isHttpUrl(v) || isFileUrl(v)) {
599
+ try {
600
+ return new URL(v).href;
601
+ }
602
+ catch {
603
+ return '';
604
+ }
605
+ }
606
+ return v.startsWith('/') ? pathToFileUrl(v) : '';
607
+ }
608
+ // What the location box holds, in the words its buttons use: a URL, or
609
+ // a local file (a manifest) or directory. Null when it's empty.
610
+ function locationNoun(value) {
611
+ const v = value.trim();
612
+ if (!v)
613
+ return null;
614
+ if (isHttpUrl(v))
615
+ return { copy: 'URL', open: 'URL' };
616
+ if (isManifestCollection(v))
617
+ return { copy: 'filename', open: 'file' };
618
+ return { copy: 'directory', open: 'directory' };
619
+ }