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,1144 @@
|
|
|
1
|
+
// The "Other versions" panel — one table of every installed copy of
|
|
2
|
+
// the script being viewed, shared by the installed-script viewer and
|
|
3
|
+
// the not-yet-installed preview.
|
|
4
|
+
//
|
|
5
|
+
// Both modes render the same rows from the same code. What differs is
|
|
6
|
+
// only which actions each row offers, which the caller supplies as
|
|
7
|
+
// callbacks; the renderer decides *whether* a row can offer them from
|
|
8
|
+
// the row's own relationship to the script on screen:
|
|
9
|
+
//
|
|
10
|
+
// - **Same location** rows are the same bytes, so a refetch is
|
|
11
|
+
// meaningful — refetching that row is refetching this script. The
|
|
12
|
+
// button takes the name the surrounding page already uses for the
|
|
13
|
+
// same call: Refresh in the viewer, Update in a preview.
|
|
14
|
+
// - **Same identity** rows are a different file that happens to be
|
|
15
|
+
// the same script. We cannot install one over the other (a
|
|
16
|
+
// `file://` copy can't overwrite an http one, and two mirrors are
|
|
17
|
+
// two separate subscriptions), so those rows link out and toggle,
|
|
18
|
+
// nothing more.
|
|
19
|
+
//
|
|
20
|
+
// See [docs/script-identity.md].
|
|
21
|
+
import { isContainerCollection, scriptAbsolutePath } from './directories.js';
|
|
22
|
+
import { appendWithSlashBreaks, displayLocation, viewerUrl, } from '../script-display.js';
|
|
23
|
+
import { isScriptEnabled, isCollectionEnabled } from './script-store.js';
|
|
24
|
+
import { COLLECTION_OFF_TITLE, effectiveDisplayName, makeCollectionTypeBubble, collectionAnchorId, collectionHasManifest, } from './collection-display.js';
|
|
25
|
+
import { openExtensionTab, wireNamedTabLink } from './page-links.js';
|
|
26
|
+
import { getValue } from '../userscript-header.js';
|
|
27
|
+
import { guardSelectOpeningClick } from './select-open-guard.js';
|
|
28
|
+
import { setDeleteIcon } from './delete-icon.js';
|
|
29
|
+
import { setGlyphButton } from './glyph.js';
|
|
30
|
+
import { markFileChanges, scriptFileTag, tagLocalFile } from './file-changes.js';
|
|
31
|
+
import { enableTitle, watchSoloModifier } from './solo-click.js';
|
|
32
|
+
import { allRelatedRows, contentFingerprint, enableChanges, otherActiveCopies, } from './script-identity.js';
|
|
33
|
+
// Render into `container`, or clear it when there is nothing related.
|
|
34
|
+
// An empty panel is emptied rather than hidden so the caller's
|
|
35
|
+
// `:empty { display: none }` rule does the hiding — same shape as the
|
|
36
|
+
// details panel next to it.
|
|
37
|
+
export function renderRelatedVersions(container, model) {
|
|
38
|
+
container.replaceChildren();
|
|
39
|
+
const { sameLocation, sameIdentity } = model;
|
|
40
|
+
// No install action means no offer section: an offer whose button
|
|
41
|
+
// the caller has decided against would contradict the page around
|
|
42
|
+
// it. Callers gate on:
|
|
43
|
+
// - installed mode — an Install there would add a SECOND row for a
|
|
44
|
+
// file already installed, and two enabled rows at one URL inject
|
|
45
|
+
// the script twice;
|
|
46
|
+
// - an invalid preview — the page has already said this file
|
|
47
|
+
// cannot be installed.
|
|
48
|
+
const offer = model.actions.install ? model.installOptions : undefined;
|
|
49
|
+
if (!sameLocation.length && !sameIdentity.length) {
|
|
50
|
+
// A preview with nothing installed anywhere has no *existing*
|
|
51
|
+
// versions to head a panel with, and the offer section would only
|
|
52
|
+
// repeat the page's own Install callout one line lower. The
|
|
53
|
+
// installed viewer never reaches this branch — its own row is
|
|
54
|
+
// always in `sameLocation`.
|
|
55
|
+
panelHeadDrawn = false;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
// A panel whose only row is the script already on screen tells the
|
|
59
|
+
// user nothing the page around it doesn't. The current row earns its
|
|
60
|
+
// place only when there is something to compare it against.
|
|
61
|
+
const onlySelf = sameIdentity.length === 0
|
|
62
|
+
&& !offer
|
|
63
|
+
&& sameLocation.length === 1
|
|
64
|
+
&& sameLocation[0].script.id === model.actions.currentScriptId;
|
|
65
|
+
if (onlySelf) {
|
|
66
|
+
panelHeadDrawn = false;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
// Every row the panel is about to draw, in one list. Enabling a copy
|
|
70
|
+
// disables the others, and "the others" spans both sections — the
|
|
71
|
+
// point is that one copy of this script runs, not one per section.
|
|
72
|
+
// Same helper the controls-row toggle uses, so both see one list.
|
|
73
|
+
const allRows = allRelatedRows(model);
|
|
74
|
+
// Installed only. A preview draws this table inside the "This script
|
|
75
|
+
// is already installed" banner, whose one line already says what the
|
|
76
|
+
// rows are and what to do with them — a heading under it would be a
|
|
77
|
+
// second, quieter statement of the same thing.
|
|
78
|
+
if (model.mode === 'installed') {
|
|
79
|
+
const head = document.createElement('div');
|
|
80
|
+
head.className = 'versions-head';
|
|
81
|
+
const h2 = document.createElement('h2');
|
|
82
|
+
h2.textContent = 'Other versions of this script';
|
|
83
|
+
head.appendChild(h2);
|
|
84
|
+
// A panel that is only now appearing — a second copy just landed
|
|
85
|
+
// on a page that had none to show — opens, whatever a fold earlier
|
|
86
|
+
// on this page said: the new row is the thing to look at.
|
|
87
|
+
if (!panelHeadDrawn)
|
|
88
|
+
setPanelCollapsed(false);
|
|
89
|
+
panelHeadDrawn = true;
|
|
90
|
+
head.appendChild(collapseToggle(container, model));
|
|
91
|
+
container.appendChild(head);
|
|
92
|
+
if (isPanelCollapsed())
|
|
93
|
+
head.classList.add('collapsed');
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
// A preview draws the rows headless, so there is no headed panel
|
|
97
|
+
// now — and the flag is about the headed one.
|
|
98
|
+
panelHeadDrawn = false;
|
|
99
|
+
}
|
|
100
|
+
// Above the fold: the panel's own failure line is the one thing in
|
|
101
|
+
// here that is time-sensitive, and a report of a refresh that just
|
|
102
|
+
// failed must not be held back until the user happens to unfold.
|
|
103
|
+
if (model.message) {
|
|
104
|
+
const p = document.createElement('p');
|
|
105
|
+
p.className = 'versions-message';
|
|
106
|
+
p.setAttribute('role', 'status');
|
|
107
|
+
p.textContent = model.message;
|
|
108
|
+
container.appendChild(p);
|
|
109
|
+
}
|
|
110
|
+
// Collapsed: the heading line, its toggle, and any message ARE the
|
|
111
|
+
// panel. The rows are left undrawn rather than drawn and hidden —
|
|
112
|
+
// hidden rows would still be found by the panel's keyboard walk and
|
|
113
|
+
// by the repaint's focus bookkeeping, and every repaint would build
|
|
114
|
+
// controls nobody can reach.
|
|
115
|
+
if (model.mode === 'installed' && isPanelCollapsed())
|
|
116
|
+
return;
|
|
117
|
+
// Installed, this section is never empty: the page's own row is a
|
|
118
|
+
// same-location row of the anchor, or the panel re-anchors on it —
|
|
119
|
+
// so the "…all" control built here is always built.
|
|
120
|
+
if (sameLocation.length) {
|
|
121
|
+
// "This URL" rather than "Same URL": the section also covers the
|
|
122
|
+
// row the user is looking at, so it isn't "same as" anything.
|
|
123
|
+
container.appendChild(sectionHeading('This URL'));
|
|
124
|
+
// Every row here loads from the same place, so the refetch means
|
|
125
|
+
// the same thing on all of them — including the current script's
|
|
126
|
+
// own row. That duplicates the button at the top of the viewer,
|
|
127
|
+
// which is fine: a table where one row is missing the control its
|
|
128
|
+
// neighbours have reads as though that row couldn't be refreshed.
|
|
129
|
+
//
|
|
130
|
+
// The "…all" control sits in this first table's header but is the
|
|
131
|
+
// PANEL's: it covers every row `actionsCell` gives a button to —
|
|
132
|
+
// in the installed viewer, the different-URL section too, since a
|
|
133
|
+
// refresh there is the same per-row refetch (`updatableRows`).
|
|
134
|
+
const verb = refetchVerb(model.mode);
|
|
135
|
+
const updatable = updatableRows(model).map((r) => r.script.id);
|
|
136
|
+
const scope = model.mode === 'preview' ? 'all copies at this URL' : 'all copies of this script';
|
|
137
|
+
const table = makeTable({
|
|
138
|
+
headerAction: updatable.length > 1 && model.actions.update
|
|
139
|
+
? (model.mode === 'preview'
|
|
140
|
+
? button(`${verb} all`, `${verb} ${scope}`, () => void model.actions.update?.(updatable))
|
|
141
|
+
: glyphButton('#sm-refresh', `${verb} ${scope}`, () => void model.actions.update?.(updatable), `${verb} all`))
|
|
142
|
+
: undefined,
|
|
143
|
+
});
|
|
144
|
+
for (const rel of sameLocation) {
|
|
145
|
+
table.tBodies[0].appendChild(versionRow(rel, model, verb, allRows));
|
|
146
|
+
}
|
|
147
|
+
container.appendChild(table);
|
|
148
|
+
}
|
|
149
|
+
if (sameIdentity.length) {
|
|
150
|
+
container.appendChild(sectionHeading('Same script from a different URL', 'same @name and @namespace'));
|
|
151
|
+
const table = makeTable();
|
|
152
|
+
for (const rel of sameIdentity) {
|
|
153
|
+
table.tBodies[0].appendChild(versionRow(rel, model, refetchVerb(model.mode), allRows));
|
|
154
|
+
}
|
|
155
|
+
container.appendChild(table);
|
|
156
|
+
}
|
|
157
|
+
// Third and last section: the copy you do NOT have yet. A section of
|
|
158
|
+
// its own rather than a row tacked onto "This URL" — it is an offer,
|
|
159
|
+
// not an inventory entry, and inside the inventory its dropdown read
|
|
160
|
+
// as one more cell of data with its button stranded in the actions
|
|
161
|
+
// column at the far right of the table.
|
|
162
|
+
if (offer) {
|
|
163
|
+
container.appendChild(sectionHeading('Install another copy of this script'));
|
|
164
|
+
// The heading row is there but off screen: the section above has
|
|
165
|
+
// already shown the reader these labels, and this table's own
|
|
166
|
+
// left-hand columns are given over to the install controls — but a
|
|
167
|
+
// screen reader has no link between two tables, so the row still
|
|
168
|
+
// needs headers of its own to announce against.
|
|
169
|
+
const table = makeTable({ headings: 'hidden' });
|
|
170
|
+
table.tBodies[0].appendChild(installRow(offer, model));
|
|
171
|
+
container.appendChild(table);
|
|
172
|
+
}
|
|
173
|
+
// Last, once every row all three tables will hold is in the document:
|
|
174
|
+
// the fit is across all of them, and a column measured before its
|
|
175
|
+
// widest row existed would be too narrow for it.
|
|
176
|
+
fitColumns(container);
|
|
177
|
+
// And after that: a chip on any local row whose file has gone or
|
|
178
|
+
// changed (file-changes.ts). Async, so it lands after the fit; the
|
|
179
|
+
// chip is short and the column has room for it.
|
|
180
|
+
void markFileChanges(container);
|
|
181
|
+
}
|
|
182
|
+
// Whether the installed viewer's panel is folded down to its heading
|
|
183
|
+
// line. The table is the page's least urgent content and can run to
|
|
184
|
+
// many rows, so it gets a way out of the way — but only in the
|
|
185
|
+
// installed viewer, where the heading it folds into exists. A preview
|
|
186
|
+
// draws the same rows headless inside its banner, with nothing to
|
|
187
|
+
// leave behind.
|
|
188
|
+
//
|
|
189
|
+
// Open by default, and a fold lasts only as long as the page: it is
|
|
190
|
+
// module state, deliberately not persisted. Switching to another copy
|
|
191
|
+
// from the table happens in place (`swapToScript`), so the fold
|
|
192
|
+
// survives the hop without any storage; a reload or a second viewer
|
|
193
|
+
// tab starts open again, which is what a view preference that nobody
|
|
194
|
+
// set on purpose should do.
|
|
195
|
+
let panelCollapsed = false;
|
|
196
|
+
// Whether the last paint drew a headed panel at all. A panel that
|
|
197
|
+
// appears after a paint without one is opened regardless of the fold
|
|
198
|
+
// — see the head-building code above.
|
|
199
|
+
let panelHeadDrawn = false;
|
|
200
|
+
function isPanelCollapsed() {
|
|
201
|
+
return panelCollapsed;
|
|
202
|
+
}
|
|
203
|
+
function setPanelCollapsed(next) {
|
|
204
|
+
panelCollapsed = next;
|
|
205
|
+
}
|
|
206
|
+
// The fold control, in the panel's top-right corner. A triangle that
|
|
207
|
+
// points down while the rows are showing, and right once they are
|
|
208
|
+
// folded away.
|
|
209
|
+
function collapseToggle(container, model) {
|
|
210
|
+
const collapsed = isPanelCollapsed();
|
|
211
|
+
const btn = document.createElement('button');
|
|
212
|
+
btn.type = 'button';
|
|
213
|
+
btn.className = 'versions-collapse';
|
|
214
|
+
btn.textContent = collapsed ? '\u25B8' : '\u25BE';
|
|
215
|
+
// One string for both: the glyph names nothing on its own, so the
|
|
216
|
+
// label a screen reader reads and the tooltip a pointer gets have to
|
|
217
|
+
// be the same sentence — same as the row's Remove button.
|
|
218
|
+
const label = collapsed ? 'Show the other versions' : 'Hide the other versions';
|
|
219
|
+
btn.setAttribute('aria-label', label);
|
|
220
|
+
btn.title = label;
|
|
221
|
+
btn.setAttribute('aria-expanded', String(!collapsed));
|
|
222
|
+
// The rows it folds are siblings of the button rather than inside
|
|
223
|
+
// it, so the relationship has to be stated rather than implied. Only
|
|
224
|
+
// when the panel has an id to point at — the viewer's does.
|
|
225
|
+
if (container.id)
|
|
226
|
+
btn.setAttribute('aria-controls', container.id);
|
|
227
|
+
btn.addEventListener('click', () => {
|
|
228
|
+
// Read the flag rather than the captured value, so a path that
|
|
229
|
+
// changes the fold without a repaint can't be toggled backwards.
|
|
230
|
+
setPanelCollapsed(!isPanelCollapsed());
|
|
231
|
+
// Re-render from the model already in hand: a repaint from the
|
|
232
|
+
// store would be a round trip for a change that only concerns what
|
|
233
|
+
// is drawn. It can be a paint behind — one deferred while a
|
|
234
|
+
// `<select>` in the panel had focus — and the deferred paint that
|
|
235
|
+
// follows corrects it.
|
|
236
|
+
renderRelatedVersions(container, model);
|
|
237
|
+
// This button went out with the render that replaced it. Focus the
|
|
238
|
+
// one that took its place, so a keyboard fold can be undone with a
|
|
239
|
+
// second press.
|
|
240
|
+
container.querySelector('.versions-collapse')?.focus();
|
|
241
|
+
});
|
|
242
|
+
return btn;
|
|
243
|
+
}
|
|
244
|
+
// Columns sized to their own content rather than to a guess. Each is
|
|
245
|
+
// as wide as its widest cell (header included) across EVERY section
|
|
246
|
+
// table, so they stay aligned and Location — the column that can
|
|
247
|
+
// always use more room — gets everything left over.
|
|
248
|
+
//
|
|
249
|
+
// Why measure at all: `table-layout: fixed` is what keeps the two
|
|
250
|
+
// tables' columns aligned, and under it the `<colgroup>` decides the
|
|
251
|
+
// width outright, so a column can't shrink to fit its own content. A
|
|
252
|
+
// fixed guess has to cover the worst case (`REFRESH FAILED`, a
|
|
253
|
+
// date-shaped `2026.08.19`), which left a short `1.2.0` stranded a
|
|
254
|
+
// column's breadth from the button beside it.
|
|
255
|
+
//
|
|
256
|
+
// Only the columns whose content has a natural width. Collection and
|
|
257
|
+
// Location are the flexible pair that absorb the remainder, and the
|
|
258
|
+
// two icon columns are already exactly a control wide.
|
|
259
|
+
const FITTED_COLUMNS = ['col-status', 'col-version', 'col-compare', 'col-actions'];
|
|
260
|
+
// Measured by switching the tables to `auto` layout at `max-content`
|
|
261
|
+
// width — the one state where a cell reports what its content actually
|
|
262
|
+
// needs — then restoring. Both happen inside this call with no `await`
|
|
263
|
+
// between, so no frame is ever painted in the measuring state.
|
|
264
|
+
//
|
|
265
|
+
// The widths land as custom properties on the panel rather than as
|
|
266
|
+
// inline widths on the `<col>`s: an inline width would beat the
|
|
267
|
+
// stylesheet's narrow-window rule, which zeroes Version below 640px.
|
|
268
|
+
function fitColumns(container) {
|
|
269
|
+
const tables = [...container.querySelectorAll('.versions-table')];
|
|
270
|
+
if (tables.length === 0)
|
|
271
|
+
return;
|
|
272
|
+
// The `<col>` widths have to come off for the measurement too: under
|
|
273
|
+
// `auto` layout a colgroup width is still a suggestion the browser
|
|
274
|
+
// honours, so leaving them on measures the guess we are trying to
|
|
275
|
+
// replace.
|
|
276
|
+
const cols = [...container.querySelectorAll('.versions-table col')];
|
|
277
|
+
for (const col of cols)
|
|
278
|
+
col.style.width = 'auto';
|
|
279
|
+
// The header's Refresh all is taken out of the measurement entirely,
|
|
280
|
+
// not merely skipped when its own cell is read. It spans Actions and
|
|
281
|
+
// Remove (see `appendHeadings`), and under `auto` layout the browser
|
|
282
|
+
// pushes a spanning cell's excess width down INTO the columns it
|
|
283
|
+
// covers — so a cell we never read still widens Actions to the
|
|
284
|
+
// header button, which is the fit this whole pass exists to avoid.
|
|
285
|
+
// Left in the row but not laid out; `fitSpannedHeader` below gives
|
|
286
|
+
// it back the room it needs afterwards.
|
|
287
|
+
const spanHead = container.querySelector('.versions-table th.col-actions[colspan]');
|
|
288
|
+
if (spanHead)
|
|
289
|
+
spanHead.style.display = 'none';
|
|
290
|
+
for (const table of tables) {
|
|
291
|
+
table.style.tableLayout = 'auto';
|
|
292
|
+
table.style.width = 'max-content';
|
|
293
|
+
}
|
|
294
|
+
const widths = new Map();
|
|
295
|
+
for (const table of tables) {
|
|
296
|
+
for (const cls of FITTED_COLUMNS) {
|
|
297
|
+
for (const cell of table.querySelectorAll(`td.${cls}, th.${cls}`)) {
|
|
298
|
+
widths.set(cls, Math.max(widths.get(cls) ?? 0, cell.getBoundingClientRect().width));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
for (const table of tables) {
|
|
303
|
+
table.style.tableLayout = '';
|
|
304
|
+
table.style.width = '';
|
|
305
|
+
}
|
|
306
|
+
for (const col of cols)
|
|
307
|
+
col.style.width = '';
|
|
308
|
+
if (spanHead)
|
|
309
|
+
spanHead.style.display = '';
|
|
310
|
+
for (const cls of FITTED_COLUMNS) {
|
|
311
|
+
const width = widths.get(cls);
|
|
312
|
+
// A panel measured while hidden reports zero. Leaving the property
|
|
313
|
+
// unset falls back to the stylesheet's worst-case width, which is
|
|
314
|
+
// wrong-but-readable; a zero would collapse the column outright.
|
|
315
|
+
if (width)
|
|
316
|
+
container.style.setProperty(`--w-${cls}`, `${Math.ceil(width)}px`);
|
|
317
|
+
}
|
|
318
|
+
if (spanHead)
|
|
319
|
+
fitSpannedHeader(container, spanHead);
|
|
320
|
+
}
|
|
321
|
+
// Widen Actions if the header button, which covers Actions and Remove,
|
|
322
|
+
// would otherwise hang off the table's right edge — which it will
|
|
323
|
+
// whenever its words are wider than a row's icon button plus a can.
|
|
324
|
+
//
|
|
325
|
+
// Everything here is read AFTER the widths above have landed, in the
|
|
326
|
+
// layout the panel will keep: the button's own width (it is never
|
|
327
|
+
// stretched, so its cell's width doesn't decide it) against the width
|
|
328
|
+
// Remove actually rendered at. Remove is not a fitted column — it is
|
|
329
|
+
// one can wide, from the stylesheet — so there is nothing measured to
|
|
330
|
+
// ask instead. A second layout, still with no `await` between, so no
|
|
331
|
+
// frame is painted in the middle.
|
|
332
|
+
//
|
|
333
|
+
// Either column can be read from any table in the panel: both are
|
|
334
|
+
// container-wide, `--w-col-actions` by construction and Remove because
|
|
335
|
+
// its width is a constant.
|
|
336
|
+
function fitSpannedHeader(container, spanHead) {
|
|
337
|
+
const action = spanHead.firstElementChild;
|
|
338
|
+
const remove = container.querySelector('.versions-table td.col-remove');
|
|
339
|
+
const actions = container.querySelector('.versions-table td.col-actions');
|
|
340
|
+
if (!action || !remove || !actions)
|
|
341
|
+
return;
|
|
342
|
+
const need = Math.ceil(action.getBoundingClientRect().width - remove.getBoundingClientRect().width);
|
|
343
|
+
if (need > actions.getBoundingClientRect().width) {
|
|
344
|
+
container.style.setProperty('--w-col-actions', `${need}px`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// One call, two operations as far as the user is concerned — the same
|
|
348
|
+
// `updateScript(id, 'manual', 'force')`, named for the page it appears
|
|
349
|
+
// on. The installed viewer's own control says Refresh, so the panel's
|
|
350
|
+
// rows do too; a preview is about bringing a copy up to the file
|
|
351
|
+
// already on screen, so there they say Update. A third name would read
|
|
352
|
+
// as a third operation.
|
|
353
|
+
//
|
|
354
|
+
// The two also LOOK different, which is `actionsCell`'s business:
|
|
355
|
+
// Refresh is the arrow glyph, Update stays a word. A preview fetches
|
|
356
|
+
// nothing — the file is already downloaded — so the refresh arrow
|
|
357
|
+
// would be a promise of a round trip that never happens.
|
|
358
|
+
function refetchVerb(mode) {
|
|
359
|
+
return mode === 'installed' ? 'Refresh' : 'Update';
|
|
360
|
+
}
|
|
361
|
+
// Which rows get the refetch button, and so which ones "…all" covers.
|
|
362
|
+
//
|
|
363
|
+
// Installed: every row. The refetch is per row — each copy is re-read
|
|
364
|
+
// from its own collection — so a different-URL copy refreshes as well
|
|
365
|
+
// as a same-URL one, and a row wearing a `modified` chip always has
|
|
366
|
+
// the button that acts on it. (The panel files its sections by the
|
|
367
|
+
// script the page was opened with, so after an in-page switch the copy
|
|
368
|
+
// on screen may well sit in the different-URL section.)
|
|
369
|
+
//
|
|
370
|
+
// Preview: same-location rows only. The call is the same per-row
|
|
371
|
+
// refetch, but a preview is about the URL on screen, and Update there
|
|
372
|
+
// means "bring this copy up to what you are looking at" — which a
|
|
373
|
+
// different-URL row, refetching its own upstream, would not be.
|
|
374
|
+
function updatableRows(model) {
|
|
375
|
+
return model.mode === 'preview' ? model.sameLocation : allRelatedRows(model);
|
|
376
|
+
}
|
|
377
|
+
function rowUpdatable(rel, mode) {
|
|
378
|
+
return mode === 'preview' ? rel.sameLocation : true;
|
|
379
|
+
}
|
|
380
|
+
// A section heading with an optional parenthesized gloss. The gloss
|
|
381
|
+
// says which of the two matches produced the rows below, so it has to
|
|
382
|
+
// be visible — but it's an aside to the heading, not part of it, and
|
|
383
|
+
// carrying the heading's weight would make the two sections read as
|
|
384
|
+
// equally shouty. It rides inside the `<h3>` (it belongs to the
|
|
385
|
+
// heading semantically) with the emphasis turned back off.
|
|
386
|
+
function sectionHeading(text, gloss) {
|
|
387
|
+
const h3 = document.createElement('h3');
|
|
388
|
+
h3.className = 'versions-section-heading';
|
|
389
|
+
h3.appendChild(document.createTextNode(text));
|
|
390
|
+
if (gloss) {
|
|
391
|
+
const span = document.createElement('span');
|
|
392
|
+
span.className = 'versions-heading-gloss';
|
|
393
|
+
span.textContent = ` (${gloss})`;
|
|
394
|
+
h3.appendChild(span);
|
|
395
|
+
}
|
|
396
|
+
return h3;
|
|
397
|
+
}
|
|
398
|
+
// `headerAction` rides in the actions column of the header row, where
|
|
399
|
+
// it lines up under nothing and over every per-row button — the
|
|
400
|
+
// natural home for a control that acts on the whole section.
|
|
401
|
+
//
|
|
402
|
+
// Each section is a separate `<table>`, so their columns would size
|
|
403
|
+
// independently and the panel would read as unrelated grids. A
|
|
404
|
+
// `<colgroup>` plus `table-layout: fixed` (in the stylesheet) pins
|
|
405
|
+
// every column to the same width in all of them.
|
|
406
|
+
//
|
|
407
|
+
// `headings: 'hidden'` keeps the heading row but takes it off screen —
|
|
408
|
+
// for a section drawn under one whose labels the reader has already
|
|
409
|
+
// seen. Off screen rather than dropped: the visual labelling carries
|
|
410
|
+
// over between the tables, but the ACCESSIBILITY tree has no link
|
|
411
|
+
// between them, and a row of cells with no `<th>` in their own table
|
|
412
|
+
// announces as four unlabelled values.
|
|
413
|
+
function makeTable(opts = {}) {
|
|
414
|
+
const table = document.createElement('table');
|
|
415
|
+
table.className = 'versions-table';
|
|
416
|
+
const cols = document.createElement('colgroup');
|
|
417
|
+
for (const cls of [
|
|
418
|
+
'col-enabled',
|
|
419
|
+
'col-collection',
|
|
420
|
+
'col-location',
|
|
421
|
+
'col-status',
|
|
422
|
+
'col-version',
|
|
423
|
+
'col-compare',
|
|
424
|
+
'col-actions',
|
|
425
|
+
'col-remove',
|
|
426
|
+
]) {
|
|
427
|
+
const col = document.createElement('col');
|
|
428
|
+
col.className = cls;
|
|
429
|
+
cols.appendChild(col);
|
|
430
|
+
}
|
|
431
|
+
table.appendChild(cols);
|
|
432
|
+
appendHeadings(table, opts.headerAction, opts.headings === 'hidden');
|
|
433
|
+
table.createTBody();
|
|
434
|
+
return table;
|
|
435
|
+
}
|
|
436
|
+
function appendHeadings(table, headerAction, hidden) {
|
|
437
|
+
const thead = table.createTHead();
|
|
438
|
+
if (hidden)
|
|
439
|
+
thead.className = 'visually-hidden';
|
|
440
|
+
const head = thead.insertRow();
|
|
441
|
+
for (const [label, cls] of [
|
|
442
|
+
['On', 'col-enabled'],
|
|
443
|
+
['Collection', 'col-collection'],
|
|
444
|
+
['Location', 'col-location'],
|
|
445
|
+
['Status', 'col-status'],
|
|
446
|
+
['Version', 'col-version'],
|
|
447
|
+
['Compare', 'col-compare'],
|
|
448
|
+
['', 'col-actions'],
|
|
449
|
+
['', 'col-remove'],
|
|
450
|
+
]) {
|
|
451
|
+
// The header action (Refresh all) is the table's own control, not
|
|
452
|
+
// the actions column's heading, so it spans that column and the
|
|
453
|
+
// removal one and sits flush with the table's right edge. Without
|
|
454
|
+
// the span it stops a can's width short of it, which reads as a
|
|
455
|
+
// button that missed its alignment rather than as a heading.
|
|
456
|
+
if (cls === 'col-remove' && headerAction)
|
|
457
|
+
continue;
|
|
458
|
+
const th = document.createElement('th');
|
|
459
|
+
th.className = cls;
|
|
460
|
+
if (cls === 'col-actions' && headerAction) {
|
|
461
|
+
// No `scope`: this cell holds the table's own control, not a
|
|
462
|
+
// heading for anything under it. (`colgroup` would be the value
|
|
463
|
+
// if it did head both columns.)
|
|
464
|
+
th.colSpan = 2;
|
|
465
|
+
th.appendChild(headerAction);
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
th.scope = 'col';
|
|
469
|
+
th.textContent = label;
|
|
470
|
+
}
|
|
471
|
+
head.appendChild(th);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
function versionRow(rel, model, verb, allRows) {
|
|
475
|
+
const { script } = rel;
|
|
476
|
+
const { actions, mode } = model;
|
|
477
|
+
const status = model.status?.get(script.id);
|
|
478
|
+
// This row is a local copy whose file the page can delete — see
|
|
479
|
+
// `RelatedVersionsModel.canDeleteFile`.
|
|
480
|
+
const deletesFile = model.canDeleteFile?.(script, rel.collection) ?? false;
|
|
481
|
+
const isCurrent = script.id === actions.currentScriptId;
|
|
482
|
+
const tr = document.createElement('tr');
|
|
483
|
+
if (isCurrent) {
|
|
484
|
+
tr.className = 'is-current';
|
|
485
|
+
// The `(this page)` marker is a run of text in the Location cell;
|
|
486
|
+
// to a screen reader it reads as more path, not as "you are here".
|
|
487
|
+
// This says it as state.
|
|
488
|
+
tr.setAttribute('aria-current', 'true');
|
|
489
|
+
// Focusable even though activating it does nothing — it is already
|
|
490
|
+
// the copy on screen. Two things need it to hold focus: Up/Down
|
|
491
|
+
// walk the table row by row, and a row that cannot take focus
|
|
492
|
+
// would be a hole in that walk; and it is where focus lands after
|
|
493
|
+
// a selection, so the user stays where they were standing.
|
|
494
|
+
//
|
|
495
|
+
// No `role`/`aria-label` to go with the tab stop, unlike the
|
|
496
|
+
// navigable rows: this one does nothing when activated, so it
|
|
497
|
+
// stays an ordinary `row` and keeps its cells' column
|
|
498
|
+
// associations. `aria-current` above is what names it.
|
|
499
|
+
if (mode === 'installed')
|
|
500
|
+
tr.tabIndex = 0;
|
|
501
|
+
}
|
|
502
|
+
else if (mode === 'installed') {
|
|
503
|
+
// The whole row selects this copy — the viewer picks the click up
|
|
504
|
+
// off the row (see its `navigateToScript`). Marked here rather than
|
|
505
|
+
// assumed there so the pointer cursor and the row that acts are the
|
|
506
|
+
// same set, and so a preview — which has no script to swap and
|
|
507
|
+
// navigates for real — gets neither.
|
|
508
|
+
tr.className = 'is-navigable';
|
|
509
|
+
// The row IS the control, so it takes the tab stop its Location
|
|
510
|
+
// cell gave up when that stopped being a link: same number of stops
|
|
511
|
+
// through the table as before, with Enter / Space on the row
|
|
512
|
+
// handled by the viewer's keydown listener.
|
|
513
|
+
tr.tabIndex = 0;
|
|
514
|
+
// And it has to SAY so. A focusable element that does something
|
|
515
|
+
// nameless is worse than the link it replaced: `role` is what makes
|
|
516
|
+
// Enter an announced affordance, and the label is what says where
|
|
517
|
+
// it goes — the cells alone read as a row of values.
|
|
518
|
+
//
|
|
519
|
+
// The cost, stated plainly: this row stops being a `row` to a
|
|
520
|
+
// screen reader, so its cells lose their column associations. Only
|
|
521
|
+
// the navigable rows pay it — the current row and every preview row
|
|
522
|
+
// keep ordinary table semantics — and a row whose whole purpose is
|
|
523
|
+
// to be activated is better announced as the link it behaves like.
|
|
524
|
+
tr.setAttribute('role', 'link');
|
|
525
|
+
tr.setAttribute('aria-label', `Open ${displayLocation(script.relativePath)}`);
|
|
526
|
+
}
|
|
527
|
+
tr.dataset.scriptId = script.id;
|
|
528
|
+
tr.appendChild(enabledCell(rel, actions, allRows));
|
|
529
|
+
tr.appendChild(collectionCell(rel));
|
|
530
|
+
tr.appendChild(locationCell(rel, isCurrent, mode));
|
|
531
|
+
tr.appendChild(statusCell(script.id, status));
|
|
532
|
+
tr.appendChild(versionCell(script));
|
|
533
|
+
tr.appendChild(contentCell(isCurrent ? undefined : script, model));
|
|
534
|
+
tr.appendChild(actionsCell(rel, actions, isCurrent, verb, mode));
|
|
535
|
+
tr.appendChild(removeCell(rel, actions, deletesFile));
|
|
536
|
+
return tr;
|
|
537
|
+
}
|
|
538
|
+
// The per-script toggle, live. This is the panel's whole reason for
|
|
539
|
+
// carrying a checkbox: with several copies of one script installed,
|
|
540
|
+
// deciding which one runs is the action the user came here for, and
|
|
541
|
+
// making them visit each script's own page to do it defeats the
|
|
542
|
+
// point.
|
|
543
|
+
//
|
|
544
|
+
// Enabling is EXCLUSIVE: two enabled copies of one script both
|
|
545
|
+
// register and both run, which is almost never what the user wants, so
|
|
546
|
+
// turning one on turns the rest off. Turning one off is not — that
|
|
547
|
+
// leaves none running, which is a perfectly ordinary thing to want and
|
|
548
|
+
// has no other copy to promote. Ctrl/Cmd-click opts out and toggles
|
|
549
|
+
// just the one box, for the rare case where two copies really are
|
|
550
|
+
// meant to run (say, two `@match`-disjoint edits of one script).
|
|
551
|
+
function enabledCell(rel, actions, allRows) {
|
|
552
|
+
const { script } = rel;
|
|
553
|
+
const td = cell('col-enabled');
|
|
554
|
+
const label = document.createElement('label');
|
|
555
|
+
label.className = 'toggle';
|
|
556
|
+
const box = document.createElement('input');
|
|
557
|
+
box.type = 'checkbox';
|
|
558
|
+
box.checked = isScriptEnabled(script);
|
|
559
|
+
// A disabled collection disables every script under it, so a row that
|
|
560
|
+
// showed only `script.enabled` would read as running when it isn't —
|
|
561
|
+
// in a panel whose whole point is "which copy runs". Same treatment
|
|
562
|
+
// as the viewer's own toggle.
|
|
563
|
+
const collectionOff = rel.collection ? !isCollectionEnabled(rel.collection) : false;
|
|
564
|
+
if (collectionOff) {
|
|
565
|
+
box.disabled = true;
|
|
566
|
+
label.classList.add('toggle-disabled');
|
|
567
|
+
}
|
|
568
|
+
// The visible tooltip, same wording as the controls-row toggle. The
|
|
569
|
+
// hint is only worth showing where the sweep can actually fire: with
|
|
570
|
+
// nothing else running there are no "others" to keep.
|
|
571
|
+
label.title = collectionOff
|
|
572
|
+
? COLLECTION_OFF_TITLE
|
|
573
|
+
: enableTitle(otherActiveCopies(allRows, script.id).length > 0);
|
|
574
|
+
// The screen-reader name carries what the tooltip drops. The checkbox
|
|
575
|
+
// is the only content of its cell, so it has no accessible name from
|
|
576
|
+
// markup at all — and five rows announcing "Enable this script" would
|
|
577
|
+
// be indistinguishable, where a sighted user just reads across to the
|
|
578
|
+
// Collection cell.
|
|
579
|
+
const what = rel.collection ? effectiveDisplayName(rel.collection) : 'this copy';
|
|
580
|
+
box.setAttribute('aria-label', collectionOff
|
|
581
|
+
? `${what}. The collection is disabled, so this copy does not run.`
|
|
582
|
+
: `${label.title}, in ${what}`);
|
|
583
|
+
const takeSolo = watchSoloModifier(box);
|
|
584
|
+
box.onchange = () => {
|
|
585
|
+
const wanted = box.checked;
|
|
586
|
+
// Only enabling is exclusive, and only without the opt-out
|
|
587
|
+
// modifier.
|
|
588
|
+
const solo = takeSolo();
|
|
589
|
+
const changes = enableChanges(script.id, wanted, solo ? [] : allRows);
|
|
590
|
+
box.disabled = true;
|
|
591
|
+
void actions
|
|
592
|
+
.setEnabled(changes, wanted && !solo ? allRows.map((r) => r.script.id) : null)
|
|
593
|
+
// The store refused the write, so the box must not keep a value
|
|
594
|
+
// that never landed. Rarely the thing the user actually sees: the
|
|
595
|
+
// caller repaints from stored state on both paths, which replaces
|
|
596
|
+
// this node outright. This is the backstop for a caller that
|
|
597
|
+
// doesn't — the panel's contract is that a rejection means
|
|
598
|
+
// nothing landed, and a box left showing an intent that was
|
|
599
|
+
// refused is a lie.
|
|
600
|
+
.catch(() => {
|
|
601
|
+
box.checked = !wanted;
|
|
602
|
+
})
|
|
603
|
+
// No re-describe: the wording no longer depends on the box's own
|
|
604
|
+
// state, only on what else is installed — which a repaint is
|
|
605
|
+
// what re-reads.
|
|
606
|
+
.finally(() => {
|
|
607
|
+
box.disabled = false;
|
|
608
|
+
});
|
|
609
|
+
};
|
|
610
|
+
label.appendChild(box);
|
|
611
|
+
td.appendChild(label);
|
|
612
|
+
return td;
|
|
613
|
+
}
|
|
614
|
+
// Collection name links to the collections page, scrolled to that
|
|
615
|
+
// collection's section — the same `#collection-<id>` anchor the
|
|
616
|
+
// viewer's Collection row uses, so both routes land in the same place.
|
|
617
|
+
//
|
|
618
|
+
// Bubble first, then the name: same order as the details panel's
|
|
619
|
+
// Collection row and the collections page's own headings. The bubble is
|
|
620
|
+
// a fixed-width tag and the name is the variable part, so leading with
|
|
621
|
+
// it also keeps a column of rows aligned down the left edge.
|
|
622
|
+
function collectionCell(rel) {
|
|
623
|
+
const td = cell('col-collection');
|
|
624
|
+
if (!rel.collection) {
|
|
625
|
+
td.appendChild(document.createTextNode('(unknown collection)'));
|
|
626
|
+
return td;
|
|
627
|
+
}
|
|
628
|
+
td.appendChild(makeCollectionTypeBubble(rel.collection.path));
|
|
629
|
+
td.appendChild(document.createTextNode(' '));
|
|
630
|
+
td.appendChild(collectionNameLink(rel.collection));
|
|
631
|
+
return td;
|
|
632
|
+
}
|
|
633
|
+
// Back-link to a collection's section on the collections page. One
|
|
634
|
+
// helper so every row carries the same class, href, and tooltip — they
|
|
635
|
+
// drifted apart when this was written twice.
|
|
636
|
+
function collectionNameLink(collection) {
|
|
637
|
+
const a = document.createElement('a');
|
|
638
|
+
a.className = 'versions-collection-name';
|
|
639
|
+
// Opens the collections page's named tab beside this one — see
|
|
640
|
+
// src/extension/page-links.ts — rather than navigating the viewer away.
|
|
641
|
+
wireNamedTabLink(a, 'collections', collectionAnchorId(collection.id));
|
|
642
|
+
a.title = 'Show this collection on the collections page';
|
|
643
|
+
appendWithSlashBreaks(a, effectiveDisplayName(collection));
|
|
644
|
+
return a;
|
|
645
|
+
}
|
|
646
|
+
// The copy's stored location. Display decodes, the href — where there
|
|
647
|
+
// still is one — does not; see [docs/names-and-paths.md].
|
|
648
|
+
//
|
|
649
|
+
// A link only in a PREVIEW, where clicking it is the only way to reach
|
|
650
|
+
// that copy. On the details page the whole row already opens the copy,
|
|
651
|
+
// so a link inside it would offer the same destination twice and
|
|
652
|
+
// underline one cell for no reason.
|
|
653
|
+
function locationCell(rel, isCurrent, mode) {
|
|
654
|
+
const td = cell('col-location');
|
|
655
|
+
const text = displayLocation(rel.script.relativePath);
|
|
656
|
+
const title = locationTitle(rel, text);
|
|
657
|
+
// The element carrying the path is tagged with the file it names, so
|
|
658
|
+
// a deleted or changed file gets its chip (see `markFileChanges`).
|
|
659
|
+
const tagFile = (el) => {
|
|
660
|
+
const tag = rel.collection && scriptFileTag(rel.script, rel.collection.path);
|
|
661
|
+
if (tag)
|
|
662
|
+
tagLocalFile(el, tag);
|
|
663
|
+
};
|
|
664
|
+
if (isCurrent || mode === 'installed') {
|
|
665
|
+
const span = document.createElement('span');
|
|
666
|
+
if (isCurrent)
|
|
667
|
+
span.className = 'versions-current-marker';
|
|
668
|
+
if (title)
|
|
669
|
+
span.title = title;
|
|
670
|
+
appendWithSlashBreaks(span, text);
|
|
671
|
+
tagFile(span);
|
|
672
|
+
if (isCurrent) {
|
|
673
|
+
const here = document.createElement('strong');
|
|
674
|
+
here.className = 'versions-this-page';
|
|
675
|
+
here.textContent = '(this page)';
|
|
676
|
+
span.appendChild(document.createTextNode(' '));
|
|
677
|
+
span.appendChild(here);
|
|
678
|
+
}
|
|
679
|
+
td.appendChild(span);
|
|
680
|
+
return td;
|
|
681
|
+
}
|
|
682
|
+
const a = document.createElement('a');
|
|
683
|
+
a.href = viewerUrl(rel.script.id);
|
|
684
|
+
if (title)
|
|
685
|
+
a.title = title;
|
|
686
|
+
appendWithSlashBreaks(a, text);
|
|
687
|
+
tagFile(a);
|
|
688
|
+
td.appendChild(a);
|
|
689
|
+
return td;
|
|
690
|
+
}
|
|
691
|
+
// What hovering the location adds to what the cell already shows: the
|
|
692
|
+
// FULL location, resolved and unabridged.
|
|
693
|
+
//
|
|
694
|
+
// The cell shows the stored spelling, which is only ever part of the
|
|
695
|
+
// answer:
|
|
696
|
+
// - a directory or manifest collection stores the path under its
|
|
697
|
+
// root, so `gm-menu/gm-menu.user.js` in the cell is
|
|
698
|
+
// `/home/…/tests/examples/gm-menu/gm-menu.user.js` on disk — and
|
|
699
|
+
// the root is over in the Collection cell, not spliced onto it;
|
|
700
|
+
// - a github row stores the raw URL and resolves to the `blob` page
|
|
701
|
+
// URL a human would open;
|
|
702
|
+
// - a percent-encoded URL displays decoded, so the tooltip is where
|
|
703
|
+
// the literal form it was installed from survives.
|
|
704
|
+
//
|
|
705
|
+
// Empty — and so no tooltip at all — when that resolves to exactly the
|
|
706
|
+
// text already on screen, which is the common `https://…/foo.user.js`
|
|
707
|
+
// case. A tooltip repeating its own cell is noise.
|
|
708
|
+
function locationTitle(rel, shown) {
|
|
709
|
+
const full = rel.collection
|
|
710
|
+
? scriptAbsolutePath(rel.collection.path, rel.script.relativePath)
|
|
711
|
+
: rel.script.relativePath;
|
|
712
|
+
return full === shown ? '' : full;
|
|
713
|
+
}
|
|
714
|
+
// Status, linking to that copy's Log tab. Every row links, including
|
|
715
|
+
// OK ones, and the colour rather than the underline is what carries
|
|
716
|
+
// the meaning — see [docs/script-identity.md] for both arguments.
|
|
717
|
+
//
|
|
718
|
+
// The current row's href resolves to this page's own URL plus `#Log`,
|
|
719
|
+
// which is a same-document fragment navigation: it switches tabs via
|
|
720
|
+
// the ordinary hashchange listener rather than reloading. That holds
|
|
721
|
+
// only while the query string matches byte-for-byte — a viewer URL
|
|
722
|
+
// that grew a second parameter would silently turn this into a page
|
|
723
|
+
// load. Another row's href goes to another script, which the installed
|
|
724
|
+
// viewer handles in place (`navigateToScript`), landing on that copy's
|
|
725
|
+
// Log tab.
|
|
726
|
+
function statusCell(scriptId, status) {
|
|
727
|
+
const td = cell('col-status');
|
|
728
|
+
if (!status) {
|
|
729
|
+
// Nothing was passed for this row, so say nothing rather than
|
|
730
|
+
// guess: an unlabelled cell is honest, a green OK would not be.
|
|
731
|
+
td.textContent = '—';
|
|
732
|
+
return td;
|
|
733
|
+
}
|
|
734
|
+
const a = document.createElement('a');
|
|
735
|
+
// The `status-*` colour classes only — NOT the controls row's
|
|
736
|
+
// `.meta-status`, which forces `color: inherit`.
|
|
737
|
+
a.className = `status-${status.status}`;
|
|
738
|
+
a.href = viewerUrl(scriptId, 'log');
|
|
739
|
+
a.textContent = status.label;
|
|
740
|
+
// Titled only when the verdict has something to add, exactly as the
|
|
741
|
+
// details panel's own Status row does with the same view — a cell
|
|
742
|
+
// that carried a tooltip the identical verdict lacks would read as a
|
|
743
|
+
// different measurement.
|
|
744
|
+
if (status.title)
|
|
745
|
+
a.title = status.title;
|
|
746
|
+
td.appendChild(a);
|
|
747
|
+
return td;
|
|
748
|
+
}
|
|
749
|
+
function versionCell(script) {
|
|
750
|
+
const v = script.header ? getValue(script.header, 'version') : undefined;
|
|
751
|
+
return versionCellFor(v, 'This copy declares no @version');
|
|
752
|
+
}
|
|
753
|
+
function versionCellFor(version, noneTitle) {
|
|
754
|
+
const td = cell('col-version');
|
|
755
|
+
td.textContent = version ?? '—';
|
|
756
|
+
if (!version)
|
|
757
|
+
td.title = noneTitle;
|
|
758
|
+
return td;
|
|
759
|
+
}
|
|
760
|
+
// Whether this row's loaded content — body and `@require`s, see
|
|
761
|
+
// `contentFingerprint` — matches the copy the panel compares against:
|
|
762
|
+
// the script on screen, or the download a preview is showing.
|
|
763
|
+
//
|
|
764
|
+
// The reference row itself (`script` undefined) is labelled rather
|
|
765
|
+
// than left blank: a copy is neither the same as nor different from
|
|
766
|
+
// itself, and a blank there read as a missing value rather than as the
|
|
767
|
+
// row the others are measured against. `BASE` on the viewer; `NEW` on
|
|
768
|
+
// a preview, where the reference is the download and the installed
|
|
769
|
+
// rows are what it would replace. An em dash when either side has no content to
|
|
770
|
+
// compare (see `contentFingerprint`: never loaded, or `@require`s it
|
|
771
|
+
// does not hold) — for the same reason the Status cell shows one:
|
|
772
|
+
// absent is not a claim.
|
|
773
|
+
//
|
|
774
|
+
// Compared against what is LOADED, not what is on disk or at the URL:
|
|
775
|
+
// a local file edited since it was read still says Same until it is
|
|
776
|
+
// refreshed, and the Location cell's `modified` chip is what says so.
|
|
777
|
+
function contentCell(script, model) {
|
|
778
|
+
const { mode } = model;
|
|
779
|
+
const td = cell('col-compare');
|
|
780
|
+
if (!script) {
|
|
781
|
+
const span = document.createElement('span');
|
|
782
|
+
span.className = 'content-base';
|
|
783
|
+
span.textContent = mode === 'preview' ? 'NEW' : 'BASE';
|
|
784
|
+
span.title = mode === 'preview'
|
|
785
|
+
? 'The script being previewed, which the installed copies are compared against'
|
|
786
|
+
: 'The copy on screen, which the other rows are compared against';
|
|
787
|
+
td.appendChild(span);
|
|
788
|
+
return td;
|
|
789
|
+
}
|
|
790
|
+
const own = contentFingerprint(script);
|
|
791
|
+
if (own === undefined || model.content === undefined) {
|
|
792
|
+
td.textContent = '—';
|
|
793
|
+
return td;
|
|
794
|
+
}
|
|
795
|
+
const other = mode === 'preview' ? 'the script being previewed' : 'the copy on screen';
|
|
796
|
+
if (own === model.content) {
|
|
797
|
+
const span = document.createElement('span');
|
|
798
|
+
span.className = 'content-same';
|
|
799
|
+
span.textContent = 'SAME';
|
|
800
|
+
span.title = `This copy\u2019s code and @require files match ${other}`;
|
|
801
|
+
td.appendChild(span);
|
|
802
|
+
return td;
|
|
803
|
+
}
|
|
804
|
+
// A copy that differs gets a DIFF button, which opens the compare
|
|
805
|
+
// page in a new tab: the panel is where the user is working, and the
|
|
806
|
+
// compare page is a look at one copy from it. A button rather than a
|
|
807
|
+
// coloured word, because a word in a column of verdicts does not read
|
|
808
|
+
// as something to click. Without a page to link to, the verdict is
|
|
809
|
+
// the plain word.
|
|
810
|
+
const href = model.compareHref?.(script);
|
|
811
|
+
if (!href) {
|
|
812
|
+
const span = document.createElement('span');
|
|
813
|
+
span.className = 'content-different';
|
|
814
|
+
span.textContent = 'DIFFERENT';
|
|
815
|
+
span.title = `This copy\u2019s code or @require files differ from ${other}`;
|
|
816
|
+
td.appendChild(span);
|
|
817
|
+
return td;
|
|
818
|
+
}
|
|
819
|
+
const btn = document.createElement('button');
|
|
820
|
+
btn.type = 'button';
|
|
821
|
+
btn.className = 'content-diff';
|
|
822
|
+
btn.textContent = 'DIFF';
|
|
823
|
+
btn.title = `This copy\u2019s code or @require files differ from ${other}. Click to show the differences.`;
|
|
824
|
+
btn.dataset.href = href;
|
|
825
|
+
btn.addEventListener('click', () => { void openExtensionTab(href); });
|
|
826
|
+
td.appendChild(btn);
|
|
827
|
+
return td;
|
|
828
|
+
}
|
|
829
|
+
function actionsCell(rel, actions, isCurrent, verb, mode) {
|
|
830
|
+
const td = cell('col-actions');
|
|
831
|
+
// Which rows get the button is `updatableRows`'s call. The current
|
|
832
|
+
// script's own row is NOT excluded: see the call site.
|
|
833
|
+
if (rowUpdatable(rel, mode) && actions.update) {
|
|
834
|
+
const title = isCurrent
|
|
835
|
+
? `${verb} this script from its collection`
|
|
836
|
+
: `${verb} this copy from its collection`;
|
|
837
|
+
// Two different operations, so two different-looking buttons —
|
|
838
|
+
// this is the one place the panel does NOT reuse the refresh arrow
|
|
839
|
+
// (see `refetchVerb`).
|
|
840
|
+
//
|
|
841
|
+
// Installed: the arrow, the same refetch this page's own Refresh
|
|
842
|
+
// does — go and see whether the upstream moved.
|
|
843
|
+
//
|
|
844
|
+
// Preview: a word. Nothing is fetched; the file is already
|
|
845
|
+
// downloaded and sitting on screen, and the button decides whether
|
|
846
|
+
// to write it over this copy. An arrow would promise a round trip
|
|
847
|
+
// that isn't going to happen, and would make the row look like the
|
|
848
|
+
// installed case it isn't.
|
|
849
|
+
// Bold in a preview: with the file already downloaded and copies
|
|
850
|
+
// listed here, updating one of them is the likelier thing to want,
|
|
851
|
+
// so this is the row's recommended action and the header's Install
|
|
852
|
+
// gives up its emphasis to it (`showInstallCallout`).
|
|
853
|
+
td.appendChild(mode === 'preview'
|
|
854
|
+
? button(verb, title, () => void actions.update?.([rel.script.id]), true)
|
|
855
|
+
: glyphButton('#sm-refresh', title, () => void actions.update?.([rel.script.id])));
|
|
856
|
+
}
|
|
857
|
+
return td;
|
|
858
|
+
}
|
|
859
|
+
// The trash can, in its own column to the right of the refetch button —
|
|
860
|
+
// the same glyph and hover treatment as the collections page's
|
|
861
|
+
// per-script remove (`.remove-script`), so the two read as one control
|
|
862
|
+
// in two places. Its own class, because the panel sits on a different
|
|
863
|
+
// ground and the borders have to match that; and, unlike the
|
|
864
|
+
// collections page, it appears disabled rather than hidden where it
|
|
865
|
+
// can't act.
|
|
866
|
+
//
|
|
867
|
+
// Live only for scripts in a container collection (the "Scripts from
|
|
868
|
+
// local disk" / "…from web" / "…from GitHub" buckets), which the user
|
|
869
|
+
// added one at a time and can take back out the same way. A script that
|
|
870
|
+
// arrived as part of a directory, manifest, or repo collection is not
|
|
871
|
+
// independently removable: the next refresh of that collection would
|
|
872
|
+
// scan it straight back in. Rather than hide the button on those rows —
|
|
873
|
+
// leaving a ragged column and no clue why some rows can be removed — it
|
|
874
|
+
// is shown disabled, with a tooltip that says where the removal
|
|
875
|
+
// actually lives. What to call a collection that holds many scripts,
|
|
876
|
+
// when telling the user their copy can't be removed on its own.
|
|
877
|
+
//
|
|
878
|
+
// "Collection" is the name for the whole family, but it says nothing
|
|
879
|
+
// about how this one was added. "Directory" and "manifest" are the words
|
|
880
|
+
// the collections page already uses for that, so they are the ones a
|
|
881
|
+
// user can act on. A GitHub repo or `/tree/` collection is a directory
|
|
882
|
+
// of scripts too, and reads that way.
|
|
883
|
+
function collectionNoun(collection) {
|
|
884
|
+
return collection && collectionHasManifest(collection.path) ? 'manifest' : 'directory';
|
|
885
|
+
}
|
|
886
|
+
function removeCell(rel, actions, deletesFile) {
|
|
887
|
+
const td = cell('col-remove');
|
|
888
|
+
if (!actions.remove)
|
|
889
|
+
return td;
|
|
890
|
+
// A row whose collection dangles is an orphan — the thing a user
|
|
891
|
+
// would most want to clear out, and nothing will scan it back in — so
|
|
892
|
+
// it gets the live button rather than a tooltip about a collection
|
|
893
|
+
// that isn't there.
|
|
894
|
+
const removable = deletesFile
|
|
895
|
+
|| (rel.collection ? isContainerCollection(rel.collection.path) : true);
|
|
896
|
+
const b = document.createElement('button');
|
|
897
|
+
b.type = 'button';
|
|
898
|
+
b.className = 'versions-remove'; // `.icon-btn` is added by setDeleteIcon
|
|
899
|
+
// The badged can only on the row whose button takes the file with
|
|
900
|
+
// it; every other copy here is just a record.
|
|
901
|
+
setDeleteIcon(b, deletesFile ? 'file' : 'record');
|
|
902
|
+
// A local copy's button says what makes it different — in the glyph
|
|
903
|
+
// above and here again in words: it takes the file off the disk as
|
|
904
|
+
// well, which is why this row has one at all.
|
|
905
|
+
const title = deletesFile
|
|
906
|
+
? 'Delete local script file'
|
|
907
|
+
: removable
|
|
908
|
+
? 'Remove this copy of the script'
|
|
909
|
+
: `Script from ${collectionNoun(rel.collection)} collection `
|
|
910
|
+
+ 'can only be removed by removing the collection';
|
|
911
|
+
b.title = title;
|
|
912
|
+
b.setAttribute('aria-label', title);
|
|
913
|
+
if (removable) {
|
|
914
|
+
b.onclick = () => {
|
|
915
|
+
b.disabled = true;
|
|
916
|
+
void actions.remove?.(rel.script.id);
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
else {
|
|
920
|
+
b.disabled = true;
|
|
921
|
+
// Also on the cell: a disabled button is out of the tab order, so
|
|
922
|
+
// hovering anywhere in the column is the one way most users will
|
|
923
|
+
// ever meet the explanation.
|
|
924
|
+
td.title = title;
|
|
925
|
+
}
|
|
926
|
+
td.appendChild(b);
|
|
927
|
+
return td;
|
|
928
|
+
}
|
|
929
|
+
// The offer to install your own copy of the script you are looking at,
|
|
930
|
+
// somewhere of your choosing — the whole of the panel's third section.
|
|
931
|
+
//
|
|
932
|
+
// Still a row in a table like the sections above it, so the Location
|
|
933
|
+
// and Version of the copy you would be adding line up under the copies
|
|
934
|
+
// you already have. That comparison is the reason it is here rather
|
|
935
|
+
// than in a callout of its own.
|
|
936
|
+
//
|
|
937
|
+
// What it does NOT keep is the columns left of Location: a copy that
|
|
938
|
+
// does not exist has no enable box, and its collection is the question
|
|
939
|
+
// rather than an answer. Those two cells become one, holding the same
|
|
940
|
+
// question the callout asks when nothing is installed anywhere, in the
|
|
941
|
+
// same shape: Install, "into", dropdown, on one line. Here rather than
|
|
942
|
+
// in the actions column, which is a whole table's width away from the
|
|
943
|
+
// control that decides what the button would do.
|
|
944
|
+
function installRow(options, model) {
|
|
945
|
+
const actions = model.actions;
|
|
946
|
+
const tr = document.createElement('tr');
|
|
947
|
+
tr.className = 'is-offer';
|
|
948
|
+
const controls = cell('col-install');
|
|
949
|
+
controls.colSpan = 2;
|
|
950
|
+
const select = buildInstallSelect(options, { onChange: () => sync() });
|
|
951
|
+
select.id = OFFER_SELECT_ID;
|
|
952
|
+
// The button, the word, and the dropdown read as one sentence —
|
|
953
|
+
// "Install into <collection>" — with the button as its verb. The
|
|
954
|
+
// label is the connector in that sentence, so it is not a heading
|
|
955
|
+
// and is not bold; the select keeps the whole phrase as its
|
|
956
|
+
// accessible name, since "into" alone names nothing.
|
|
957
|
+
const label = document.createElement('label');
|
|
958
|
+
label.htmlFor = OFFER_SELECT_ID;
|
|
959
|
+
label.textContent = 'into';
|
|
960
|
+
select.setAttribute('aria-label', 'Install into');
|
|
961
|
+
const b = button('Install', 'Install a copy of this script into the chosen collection', () => {
|
|
962
|
+
void actions.install?.(b, selectedInstallChoice(select, options));
|
|
963
|
+
}, true);
|
|
964
|
+
const pick = document.createElement('div');
|
|
965
|
+
pick.className = 'install-offer-pick';
|
|
966
|
+
pick.append(b, label, select);
|
|
967
|
+
// The pre-selected entry can be one that already holds this script
|
|
968
|
+
// (the default stays first regardless); Install then waits for a
|
|
969
|
+
// deliberate pick, and this line says why it is off.
|
|
970
|
+
//
|
|
971
|
+
// Under the line rather than beside the button: the button leads a
|
|
972
|
+
// sentence, and a note spliced into the middle of one breaks the
|
|
973
|
+
// reading.
|
|
974
|
+
const note = document.createElement('div');
|
|
975
|
+
note.className = 'install-offer-note';
|
|
976
|
+
const sync = () => {
|
|
977
|
+
const blocked = installChoiceBlocked(select, options);
|
|
978
|
+
b.disabled = blocked;
|
|
979
|
+
note.textContent = blocked ? CHOOSE_ANOTHER_COLLECTION : '';
|
|
980
|
+
};
|
|
981
|
+
sync();
|
|
982
|
+
controls.append(pick, note);
|
|
983
|
+
tr.appendChild(controls);
|
|
984
|
+
tr.appendChild(offerLocationCell(model.location));
|
|
985
|
+
// No stored row, so no status: this file has never been loaded,
|
|
986
|
+
// registered, or logged against. An em dash, as a row whose status
|
|
987
|
+
// lookup failed also uses.
|
|
988
|
+
const stat = cell('col-status');
|
|
989
|
+
stat.className += ' versions-offer-text';
|
|
990
|
+
stat.textContent = '—';
|
|
991
|
+
tr.appendChild(stat);
|
|
992
|
+
// Not a copy — an offer — so the no-version tooltip has to be about
|
|
993
|
+
// the file being previewed, not about a row that doesn't exist.
|
|
994
|
+
tr.appendChild(versionCellFor(model.version, 'This script declares no @version'));
|
|
995
|
+
// The offer IS the download the other rows are compared against, so
|
|
996
|
+
// it is the base row here — same as the current row on the viewer.
|
|
997
|
+
tr.appendChild(contentCell(undefined, model));
|
|
998
|
+
// Nothing to refetch and nothing to remove: the copy does not exist
|
|
999
|
+
// yet. Empty cells rather than disabled controls, which would imply
|
|
1000
|
+
// one does.
|
|
1001
|
+
tr.appendChild(cell('col-actions'));
|
|
1002
|
+
tr.appendChild(cell('col-remove'));
|
|
1003
|
+
return tr;
|
|
1004
|
+
}
|
|
1005
|
+
// Where the copy this row offers would load from — the location the
|
|
1006
|
+
// whole panel was computed for, in the same column as the installed
|
|
1007
|
+
// copies' own paths.
|
|
1008
|
+
//
|
|
1009
|
+
// The ABSOLUTE location, which is not always the spelling above it: a
|
|
1010
|
+
// row inside a directory or manifest collection stores its path under
|
|
1011
|
+
// that collection's root and prints the short form. Absolute anyway,
|
|
1012
|
+
// because it is what this row's install would store — only a scripts
|
|
1013
|
+
// collection can take an individually installed script, and those keep
|
|
1014
|
+
// the location whole. The column lines the two up; it does not claim
|
|
1015
|
+
// they are spelled alike.
|
|
1016
|
+
//
|
|
1017
|
+
// Never a link: it is the page you are already on. Tooltipped with the
|
|
1018
|
+
// undecoded spelling when percent-encoding makes one differ from the
|
|
1019
|
+
// other — `locationTitle`'s decoding case, without its resolve-the-
|
|
1020
|
+
// collection-root case, which a location that is already absolute
|
|
1021
|
+
// cannot be in.
|
|
1022
|
+
function offerLocationCell(location) {
|
|
1023
|
+
const td = cell('col-location');
|
|
1024
|
+
const text = displayLocation(location);
|
|
1025
|
+
if (text !== location)
|
|
1026
|
+
td.title = location;
|
|
1027
|
+
appendWithSlashBreaks(td, text);
|
|
1028
|
+
return td;
|
|
1029
|
+
}
|
|
1030
|
+
// The offer section's dropdown, which is a labelled control and so
|
|
1031
|
+
// needs an id. Distinct from the callout's `install-target`: the callout is
|
|
1032
|
+
// hidden rather than emptied when the row takes over, so a shared id
|
|
1033
|
+
// would put two of them in the document.
|
|
1034
|
+
const OFFER_SELECT_ID = 'install-target-offer';
|
|
1035
|
+
// The `<option>` value that means "New collection…".
|
|
1036
|
+
const NEW_COLLECTION_VALUE = 'new';
|
|
1037
|
+
// Why the offer section's Install is off: the pre-selected default is a
|
|
1038
|
+
// collection that already holds this script. Only this row says it in
|
|
1039
|
+
// words — the move dialog's equivalent state is the *(current)* entry,
|
|
1040
|
+
// which is labelled rather than explained.
|
|
1041
|
+
const CHOOSE_ANOTHER_COLLECTION = 'Already installed there. Choose another collection.';
|
|
1042
|
+
// Whether the dropdown's current answer is an entry neither Install
|
|
1043
|
+
// nor Move can act on: one that already holds this script, or — for a
|
|
1044
|
+
// move — the collection it is in now. One predicate, so the two
|
|
1045
|
+
// controls can't disagree about when their button is live.
|
|
1046
|
+
export function installChoiceBlocked(select, options) {
|
|
1047
|
+
const chosen = selectedInstallChoice(select, options);
|
|
1048
|
+
return 'option' in chosen && (chosen.option.installed || chosen.option.isCurrent);
|
|
1049
|
+
}
|
|
1050
|
+
// The "Install into" dropdown, in three groups separated by rules:
|
|
1051
|
+
// the built-in default for this script's scheme (existing, or "(new)"
|
|
1052
|
+
// — created by the install); every other scripts collection in page
|
|
1053
|
+
// order; "New collection…". Entries that already hold this script are
|
|
1054
|
+
// disabled and marked. The first entry is always pre-selected, even
|
|
1055
|
+
// when it is disabled — installing a second copy is never the one-click
|
|
1056
|
+
// path.
|
|
1057
|
+
//
|
|
1058
|
+
// Every entry, "New collection…" included, only *selects*. Nothing
|
|
1059
|
+
// happens until Install is clicked.
|
|
1060
|
+
//
|
|
1061
|
+
// `<hr>` inside `<select>` is native in Chrome ≥ 119, well under the
|
|
1062
|
+
// extension's floor.
|
|
1063
|
+
export function buildInstallSelect(options, config) {
|
|
1064
|
+
const select = document.createElement('select');
|
|
1065
|
+
select.className = 'install-target';
|
|
1066
|
+
// Opens the list on the mouse release, so the click that opens it
|
|
1067
|
+
// can't also pick from it — see select-open-guard.ts.
|
|
1068
|
+
guardSelectOpeningClick(select);
|
|
1069
|
+
select.title = config.title ?? 'Which collection to install this script into';
|
|
1070
|
+
// Which entry opens selected: the one the script is already in when
|
|
1071
|
+
// there is one (the move dropdown), else the scheme default that
|
|
1072
|
+
// leads the list. Either way the opening choice is somewhere the
|
|
1073
|
+
// script already lives, so acting on it takes a deliberate pick.
|
|
1074
|
+
const preselect = Math.max(options.findIndex((o) => o.isCurrent), 0);
|
|
1075
|
+
options.forEach((o, i) => {
|
|
1076
|
+
if (i === 1)
|
|
1077
|
+
select.appendChild(document.createElement('hr'));
|
|
1078
|
+
// At most one suffix, in this order: "current" is where the script
|
|
1079
|
+
// is now; "already installed" is why another entry can't be picked;
|
|
1080
|
+
// "new" says the row doesn't exist yet; "disabled" warns about what
|
|
1081
|
+
// happens after. The first three can't co-occur with "disabled"
|
|
1082
|
+
// anyway — a default that doesn't exist is created enabled.
|
|
1083
|
+
const text = o.isCurrent ? `${o.label} (current)`
|
|
1084
|
+
: o.installed ? `${o.label} (already installed)`
|
|
1085
|
+
: !o.collection ? `${o.label} (new)`
|
|
1086
|
+
: o.collectionOff ? `${o.label} (disabled)`
|
|
1087
|
+
: o.label;
|
|
1088
|
+
const opt = new Option(text, String(i));
|
|
1089
|
+
opt.disabled = o.installed || o.isCurrent;
|
|
1090
|
+
if (i === preselect)
|
|
1091
|
+
opt.selected = true;
|
|
1092
|
+
select.appendChild(opt);
|
|
1093
|
+
});
|
|
1094
|
+
select.appendChild(document.createElement('hr'));
|
|
1095
|
+
select.appendChild(new Option('New collection…', NEW_COLLECTION_VALUE));
|
|
1096
|
+
select.onchange = () => config.onChange?.();
|
|
1097
|
+
return select;
|
|
1098
|
+
}
|
|
1099
|
+
// What a `buildInstallSelect` dropdown currently answers: one of the
|
|
1100
|
+
// listed containers, or "make me a new one". Falls back to the first
|
|
1101
|
+
// entry, since the select holds nothing else.
|
|
1102
|
+
//
|
|
1103
|
+
// The empty-list fallback is a guard, not a reachable state:
|
|
1104
|
+
// `installOptionsFor` always returns at least the scheme default. It
|
|
1105
|
+
// exists because every caller dereferences `choice.option`, so a list
|
|
1106
|
+
// that somehow came back empty must not hand them `undefined`.
|
|
1107
|
+
export function selectedInstallChoice(select, options) {
|
|
1108
|
+
if (select.value === NEW_COLLECTION_VALUE)
|
|
1109
|
+
return { newCollection: true };
|
|
1110
|
+
const option = options[Number(select.value)] ?? options[0];
|
|
1111
|
+
return option ? { option } : { newCollection: true };
|
|
1112
|
+
}
|
|
1113
|
+
function cell(cls) {
|
|
1114
|
+
const td = document.createElement('td');
|
|
1115
|
+
td.className = cls;
|
|
1116
|
+
return td;
|
|
1117
|
+
}
|
|
1118
|
+
// The bare button both builders below start from, so `type` and the
|
|
1119
|
+
// click wiring can't drift between them.
|
|
1120
|
+
function makeButton(title, onClick) {
|
|
1121
|
+
const b = document.createElement('button');
|
|
1122
|
+
b.type = 'button';
|
|
1123
|
+
b.title = title;
|
|
1124
|
+
b.onclick = onClick;
|
|
1125
|
+
return b;
|
|
1126
|
+
}
|
|
1127
|
+
// The glyph-bearing sibling of `button`. `text` is left out for an
|
|
1128
|
+
// icon-only button, where `title` is the whole label — the row buttons
|
|
1129
|
+
// — and supplied where a word carries something the arrow can't, as
|
|
1130
|
+
// "all" does in the header action.
|
|
1131
|
+
function glyphButton(symbolId, title, onClick, text) {
|
|
1132
|
+
const b = makeButton(title, onClick);
|
|
1133
|
+
// Sets the title again, and the `aria-label` an icon-only button
|
|
1134
|
+
// can't do without.
|
|
1135
|
+
setGlyphButton(b, symbolId, title, text === undefined ? {} : { text });
|
|
1136
|
+
return b;
|
|
1137
|
+
}
|
|
1138
|
+
function button(label, title, onClick, primary = false) {
|
|
1139
|
+
const b = makeButton(title, onClick);
|
|
1140
|
+
b.textContent = label;
|
|
1141
|
+
if (primary)
|
|
1142
|
+
b.className = 'primary';
|
|
1143
|
+
return b;
|
|
1144
|
+
}
|