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