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