浏览代码

feat: show dependencies right inside installer (#1327)

+ use cache for successful urls when retrying
+ show script name in title
tophf 4 年之前
父节点
当前提交
ba4fb84bc2

+ 7 - 15
src/background/utils/requests.js

@@ -1,5 +1,6 @@
 import {
   blob2base64, buffer2string, getUniqId, request, i18n, isEmpty, noop, sendTabCmd,
+  string2uint8array,
 } from '#/common';
 import { forEachEntry, objectPick } from '#/common/object';
 import ua from '#/common/ua';
@@ -387,12 +388,7 @@ function decodeBody([body, type]) {
     type = 'application/x-www-form-urlencoded';
   } else if (type) {
     // 5x times faster than fetch() which wastes time on inter-process communication
-    const bin = atob(body.slice(body.indexOf(',') + 1));
-    const len = bin.length;
-    const res = new Uint8Array(len);
-    for (let i = 0; i < len; i += 1) {
-      res[i] = bin.charCodeAt(i);
-    }
+    const res = string2uint8array(atob(body.slice(body.indexOf(',') + 1)));
     if (type === 'blob') {
       type = '';
     } else {
@@ -455,17 +451,13 @@ async function confirmInstall({ code, from, url }, { tab = {} }) {
   if (!isUserScript(code)) throw i18n('msgInvalidScript');
   cache.put(url, code, 3000);
   const confirmKey = getUniqId();
-  const { id: tabId, incognito } = tab;
+  const { active, id: tabId, incognito } = tab;
   cache.put(`confirm-${confirmKey}`, { incognito, url, from, tabId });
-  const { windowId } = await browser.tabs.create({
+  const { windowId } = await commands.TabOpen({
     url: `/confirm/index.html#${confirmKey}`,
-    index: tab.index + 1 || undefined,
-    active: !!tab.active,
-    ...tabId >= 0 && ua.openerTabIdSupported && !incognito && {
-      openerTabId: tabId,
-    },
-  });
-  if (windowId !== tab.windowId) {
+    active: !!active,
+  }, { tab });
+  if (active && windowId !== tab.windowId) {
     await browser.windows.update(windowId, { focused: true });
   }
 }

+ 19 - 16
src/background/utils/tabs.js

@@ -35,7 +35,7 @@ Object.assign(commands, {
     }
     return commands.TabOpen({ url, maybeInWindow: true });
   },
-  /** @return {Promise<{ id: number }>} */
+  /** @return {Promise<{ id: number } | chrome.tabs.Tab>} new tab is returned for internal calls */
   async TabOpen({
     url,
     active = true,
@@ -83,23 +83,26 @@ Object.assign(commands, {
         || hasPos && await browser.windows.create(wndOpts);
       newTab = wnd.tabs[0];
     }
-    const { id, windowId: newWindowId } = newTab || await browser.tabs.create({
-      url,
-      // normalizing as boolean because the API requires strict types
-      active: !!active,
-      pinned: !!pinned,
-      ...storeId,
-      ...canOpenIncognito && {
-        windowId,
-        ...insert && { index: srcTab.index + 1 },
-        ...ua.openerTabIdSupported && { openerTabId: srcTab.id },
-      },
-    });
-    if (active && newWindowId !== windowId) {
-      await browser.windows.update(newWindowId, { focused: true });
+    if (!newTab) {
+      newTab = await browser.tabs.create({
+        url,
+        // normalizing as boolean because the API requires strict types
+        active: !!active,
+        pinned: !!pinned,
+        ...storeId,
+        ...canOpenIncognito && {
+          windowId,
+          ...insert && { index: srcTab.index + 1 },
+          ...ua.openerTabIdSupported && { openerTabId: srcTab.id },
+        },
+      });
+    }
+    const { id } = newTab;
+    if (active && newTab.windowId !== windowId) {
+      await browser.windows.update(newTab.windowId, { focused: true });
     }
     openers[id] = srcTab.id;
-    return { id };
+    return isInternal ? newTab : { id };
   },
   /** @return {void} */
   TabClose({ id } = {}, src) {

+ 13 - 5
src/common/ui/code.vue

@@ -88,7 +88,7 @@ const PLACEHOLDER_CLS = 'too-long-placeholder';
 // To identify our CodeMirror markers we're using a Symbol since it's always unique
 const PLACEHOLDER_SYM = Symbol(PLACEHOLDER_CLS);
 
-export const cmOptions = {
+const cmDefaults = {
   continueComments: true,
   styleActiveLine: true,
   foldGutter: true,
@@ -122,6 +122,7 @@ export default {
       type: Object,
       default: null,
     },
+    cmOptions: Object,
   },
   components: {
     Tooltip,
@@ -129,7 +130,7 @@ export default {
   },
   data() {
     return {
-      cmOptions,
+      cmDefaults,
       content: '',
       jumpPos: '',
       search: {
@@ -154,7 +155,7 @@ export default {
   watch: {
     active: 'onActive',
     mode(value) {
-      this.cm.setOption('mode', value || cmOptions.mode);
+      this.cm.setOption('mode', value || cmDefaults.mode);
     },
     value(value) {
       const { cm } = this;
@@ -516,7 +517,13 @@ export default {
   },
   mounted() {
     let userOpts = options.get('editor');
-    const opts = { ...this.cmOptions, ...userOpts, mode: this.mode || cmOptions.mode };
+    const internalOpts = this.cmOptions || {};
+    const opts = {
+      ...cmDefaults,
+      ...userOpts,
+      ...internalOpts, // internal options passed via `props` have the highest priority
+      mode: this.mode || cmDefaults.mode,
+    };
     CodeMirror.registerHelper('hint', 'autoHintWithFallback', (cm, ...args) => {
       const result = cm.getHelper(cm.getCursor(), 'hint')?.(cm, ...args);
       // fallback to anyword if default returns nothing (or no default)
@@ -533,8 +540,9 @@ export default {
     this.onActive(true);
     hookSetting('editor', (newUserOpts) => {
       // Use defaults for keys that were present in the old userOpts but got deleted in newUserOpts
-      ({ ...this.cmOptions, ...newUserOpts })::forEachEntry(([key, val]) => {
+      ({ ...cmDefaults, ...newUserOpts })::forEachEntry(([key, val]) => {
         if ((key in newUserOpts || key in userOpts)
+        && !(key in internalOpts)
         && !deepEqual(this.cm.getOption(key), val)) {
           this.cm.setOption(key, val);
         }

+ 204 - 0
src/common/ui/externals.vue

@@ -0,0 +1,204 @@
+<template>
+  <div class="edit-externals flex flex-col">
+    <div v-if="!install || all.length > 1" class="select"
+         :data-has-main="install ? '' : null">
+      <dl v-for="([type, url, contents], i) of all" :key="i"
+          class="flex"
+          :class="{
+            active: index === i,
+            loading: install && i && !(url in install.deps),
+            error: contents === false,
+          }"
+          @click="contents !== false && (index = i)">
+        <dt v-text="type"/>
+        <dd class="ellipsis flex-1">
+          <a :href="url" target="_blank">&nearr;</a>
+          <span v-text="decodeURIComponent(url)"/>
+        </dd>
+        <dd v-if="contents" v-text="formatLength(contents)" class="ml-2"/>
+      </dl>
+    </div>
+    <div class="contents pos-rel flex-auto">
+      <img v-if="img" :src="img">
+      <vm-code
+        class="abs-full"
+        v-model="code"
+        ref="code"
+        readonly
+        :cm-options="cmOptions"
+        :mode="mode"
+      />
+    </div>
+  </div>
+</template>
+
+<script>
+import { string2uint8array } from '#/common';
+import { objectEntries } from '#/common/object';
+import VmCode from '#/common/ui/code';
+import storage from '#/common/storage';
+
+export default {
+  props: ['value', 'cmOptions', 'commands', 'install', 'errors'],
+  components: { VmCode },
+  computed: {
+    all() {
+      const { code, deps = this.deps, url: mainUrl } = this.install || {};
+      const { require = [], resources = {} } = this.value.meta || {};
+      return [
+        ...mainUrl ? [[this.i18n('editNavCode'), mainUrl, code]] : [],
+        ...require.map(url => ['@require', url, deps[url]]),
+        ...objectEntries(resources).map(([name, url]) => [`@resource ${name}`, url, deps[url]]),
+      ];
+    },
+  },
+  data() {
+    return {
+      code: null,
+      deps: {},
+      img: null,
+      index: null,
+      mode: null,
+    };
+  },
+  watch: {
+    async index(index) {
+      const [type, url] = this.all[index] || [];
+      if (!url) return;
+      const { install } = this;
+      const isMain = install && !index;
+      const isReq = !isMain && type === '@require';
+      let code;
+      let contentType;
+      let img;
+      let raw;
+      if (isMain) {
+        code = install.code;
+      } else {
+        if (install) {
+          raw = install.deps[url];
+        } else {
+          const key = this.value.custom.pathMap?.[url] || url;
+          raw = await storage[isReq ? 'require' : 'cache'].getOne(key);
+          if (!isReq) raw = storage.cache.makeDataUri(key, raw);
+        }
+        if (isReq || !raw) {
+          code = raw;
+        } else if (raw.startsWith('data:image')) {
+          img = raw;
+        } else {
+          [contentType, code] = raw.split(',');
+          if (code == null) { // workaround for bugs in old VM, see 2e135cf7
+            const fileExt = url.match(/\.(\w+)([#&?]|$)/)?.[1] || '';
+            contentType = /^(png|jpe?g|bmp|svgz?|gz|zip)$/i.test(fileExt)
+              ? ''
+              : `text/${fileExt.toLowerCase()}`;
+            code = raw;
+          }
+          code = atob(code);
+          if (/[\x80-\xFF]/.test(code)) {
+            code = new TextDecoder().decode(string2uint8array(code));
+          }
+        }
+      }
+      this.img = img;
+      this.mode = contentType === 'text/css' || /\.css([#&?]|$)/i.test(url) ? 'css' : null;
+      this.code = code;
+      this.$set(this.deps, url, code);
+    },
+    value() {
+      this.$nextTick(() => {
+        if (this.index >= this.all.length) this.index = 0;
+      });
+    },
+  },
+  async mounted() {
+    this.index = 0;
+  },
+  methods: {
+    formatLength(str) {
+      const len = str?.length;
+      return !len ? ''
+        : len < 1024 && `${len} B`
+        || len < 1024 * 1024 && `${len >> 10} k` // eslint-disable-line no-bitwise
+        || `${len >> 20} M`; // eslint-disable-line no-bitwise
+    },
+  },
+};
+</script>
+
+<style>
+$outerPadX: 1rem;
+$mainEntryBorder: 6px double;
+.edit-externals {
+  border-top: $mainEntryBorder var(--fill-8);
+  > .select {
+    min-height: 1.25rem;
+    max-height: 15vh;
+    overflow-y: auto;
+    border-bottom: 2px solid var(--fill-3);
+    padding-bottom: calc($outerPadX/2);
+    &[data-has-main] dl:first-child {
+      padding-top: .5em;
+      padding-bottom: .5em;
+      border-bottom: 1px solid var(--fill-3);
+      position: sticky;
+      top: 0;
+      background: var(--fill-0);
+    }
+    dl {
+      padding-right: $outerPadX;
+      align-items: center;
+      white-space: nowrap;
+      &.active {
+        font-weight: bold;
+      }
+      &.loading dd {
+        color: var(--fill-7);
+      }
+      &.error dd {
+        color: red;
+      }
+      &:not(.error) {
+        cursor: pointer;
+        &:hover dd {
+          text-decoration: underline;
+          a {
+            text-decoration: none;
+          }
+        }
+      }
+    }
+    dt {
+      color: darkviolet;
+      margin-left: $outerPadX;
+      font-family: monospace;
+    }
+    a {
+      padding: 0 .5em;
+      cursor: alias;
+      &:hover {
+        background: var(--fill-3);
+      }
+    }
+  }
+  > .contents {
+    > img {
+      padding: 1rem;
+      max-width: 100%;
+      max-height: 100%;
+      object-fit: contain;
+    }
+  }
+  @media (prefers-color-scheme: dark) {
+    .select {
+      &.error dd {
+        color: #ff4747;
+      }
+      dt {
+        color: #c34ec3;
+      }
+    }
+  }
+}
+</style>

+ 9 - 0
src/common/util.js

@@ -124,6 +124,15 @@ export function blob2base64(blob, offset = 0, length = 1e99) {
   });
 }
 
+export function string2uint8array(str) {
+  const len = str.length;
+  const array = new Uint8Array(len);
+  for (let i = 0; i < len; i += 1) {
+    array[i] = str.charCodeAt(i);
+  }
+  return array;
+}
+
 const VERSION_RE = /^(.*?)-([-.0-9a-z]+)|$/i;
 const DIGITS_RE = /^\d+$/; // using regexp to avoid +'1e2' being parsed as 100
 

+ 113 - 52
src/confirm/views/app.vue

@@ -2,8 +2,16 @@
   <div class="page-confirm frame flex flex-col h-100" :class="{ reinstall }">
     <div class="frame-block">
       <div class="flex">
-        <div class="image">
+        <div class="image flex flex-col self-start mb-2c">
           <img src="/public/images/icon128.png">
+          <div class="mr-1c">
+            <tooltip v-for="([url, icon, title]) in icons" :key="icon"
+                     :content="title" placement="bottom" align="left">
+              <a target="_blank" :href="url">
+                <icon :name="icon"/>
+              </a>
+            </tooltip>
+          </div>
         </div>
         <div class="info">
           <h1>
@@ -17,23 +25,17 @@
              :title="info.url" :href="info.url" @click.prevent />
           <p class="descr" v-text="descr"/>
           <div class="lists flex flex-wrap" :data-collapsed="!listsShown">
-            <tooltip :content="i18n('msgShowHide')" placement="top" v-if="lists">
-              <div class="toggle" @click="listsShown = !listsShown">
+            <div class="toggle" @click="listsShown = !listsShown">
+              <tooltip :content="i18n('msgShowHide')" placement="bottom" align="left" v-if="lists">
                 <icon name="info"/>
-              </div>
-            </tooltip>
+              </tooltip>
+            </div>
             <dl v-for="(list, name) in lists" :key="name"
                 :data-type="name" :hidden="!list.length" tabindex="0">
               <dt v-text="`@${name}`"/>
-              <dd v-if="Array.isArray(list)" class="flex flex-col">
-                <a v-for="(url, i) in list" :key="name + i"
-                   :href="url" v-text="decodeURIComponent(url)"
-                   rel="noopener noreferrer" target="_blank" :data-ok="url in urlsOK"/>
-              </dd>
-              <dd v-else v-text="list" class="ellipsis"/>
+              <dd v-text="list" class="ellipsis"/>
             </dl>
           </div>
-          <div v-text="message" :title="error"/>
         </div>
       </div>
       <div class="flex">
@@ -48,20 +50,30 @@
               : i18n('buttonConfirmInstallation')"
             @click="installScript" :disabled="!installable"/>
           <button v-text="i18n('buttonClose')" @click="close"/>
-          <setting-check name="closeAfterInstall" :label="i18n('installOptionClose')"
-                         @change="checkClose" />
-          <setting-check name="trackLocalFile" @change="trackLocalFile"
-                         :disabled="closeAfterInstall || !isLocal">
-            <tooltip :content="trackTooltip" :disabled="!trackTooltip">
-              <span v-text="i18n('installOptionTrack')"/>
-            </tooltip>
-          </setting-check>
+          <div class="flex flex-col my-1">
+            <setting-check name="closeAfterInstall" :label="i18n('installOptionClose')"
+                           @change="checkClose" />
+            <setting-check name="trackLocalFile" @change="trackLocalFile"
+                           :disabled="closeAfterInstall || !isLocal">
+              <tooltip :content="trackTooltip" :disabled="!trackTooltip">
+                <span v-text="i18n('installOptionTrack')"/>
+              </tooltip>
+            </setting-check>
+          </div>
+          <div v-text="message" v-if="message" :title="error" class="status"/>
         </div>
       </div>
       <div class="incognito" v-if="info.incognito" v-text="i18n('msgIncognitoChanges')"/>
     </div>
     <div class="frame-block flex-auto pos-rel">
-      <vm-code class="abs-full" readonly :value="code" :commands="commands" />
+      <vm-externals
+        v-if="script"
+        v-model="script"
+        class="abs-full"
+        :cm-options="cmOptions"
+        :commands="commands"
+        :install="{ code, deps, url: info.url }"
+      />
     </div>
   </div>
 </template>
@@ -76,25 +88,30 @@ import {
 import options from '#/common/options';
 import initCache from '#/common/cache';
 import storage from '#/common/storage';
-import VmCode from '#/common/ui/code';
+import VmExternals from '#/common/ui/externals';
 import SettingCheck from '#/common/ui/setting-check';
 import { loadScriptIcon } from '#/common/load-script-icon';
 import { deepEqual, objectPick } from '#/common/object';
 import { route } from '#/common/router';
 import ua from '#/common/ua';
 
-const cache = initCache({});
+const KEEP_INFO_DELAY = 5000;
+const RETRY_DELAY = 3000;
+const RETRY_COUNT = 2;
+const MAX_TITLE_NAME_LEN = 100;
+const cache = initCache({ lifetime: RETRY_DELAY * (RETRY_COUNT + 1) });
 /** @type {chrome.runtime.Port} */
 let filePort;
 /** @type {function()} */
 let filePortResolve;
 /** @type {boolean} */
 let filePortNeeded;
+let basicTitle;
 
 export default {
   components: {
     Icon,
-    VmCode,
+    VmExternals,
     SettingCheck,
     Tooltip,
   },
@@ -104,12 +121,16 @@ export default {
       installed: false,
       closeAfterInstall: options.get('closeAfterInstall'),
       message: '',
+      cmOptions: {
+        lineWrapping: true,
+      },
       code: '',
       commands: {
         close: this.close,
       },
       info: {},
       decodedUrl: '...',
+      deps: {}, // combines `this.require` and `this.resources` = all loaded deps
       descr: '',
       error: null,
       heading: this.i18n('msgLoadingData'),
@@ -119,7 +140,7 @@ export default {
       reinstall: false,
       safeIcon: null,
       sameCode: false,
-      urlsOK: {},
+      script: null,
     };
   },
   computed: {
@@ -129,6 +150,13 @@ export default {
     isLocal() {
       return !isRemote(this.info.url);
     },
+    icons() {
+      const { homepageURL, supportURL } = this.script?.meta || {};
+      return [
+        homepageURL && [homepageURL, 'home', this.i18n('labelHomepage')],
+        supportURL && [supportURL, 'question', this.i18n('buttonSupport')],
+      ].filter(Boolean);
+    },
   },
   async mounted() {
     const id = route.paths[0];
@@ -141,15 +169,15 @@ export default {
     const { url } = this.info;
     this.decodedUrl = decodeURIComponent(url);
     filePortNeeded = ua.isFirefox >= 68 && url.startsWith('file:');
-    this.guard = setInterval(sendCmdDirectly, 5000, 'CacheHit', { key });
+    this.guard = setInterval(sendCmdDirectly, KEEP_INFO_DELAY, 'CacheHit', { key });
     await this.loadData();
     await this.parseMeta();
     await Promise.all([
       this.checkSameCode(),
       (async () => {
-        let retries = 2;
-        while (retries && !await this.loadDeps()) {
-          await makePause(3000);
+        let retries = RETRY_COUNT;
+        while (!await this.loadDeps() && retries) {
+          await makePause(RETRY_DELAY);
           retries -= 1;
         }
       })(),
@@ -175,11 +203,14 @@ export default {
     },
     async parseMeta() {
       /** @type {VMScriptMeta} */
-      const script = await sendCmdDirectly('ParseMeta', this.code);
-      const urls = Object.values(script.resources);
-      this.name = [getLocaleString(script, 'name'), script.version]::trueJoin(', ');
-      this.descr = getLocaleString(script, 'description');
-      this.lists = objectPick(script, [
+      const meta = await sendCmdDirectly('ParseMeta', this.code);
+      const name = getLocaleString(meta, 'name');
+      document.title = `${name.slice(0, MAX_TITLE_NAME_LEN)}${name.length > MAX_TITLE_NAME_LEN ? '...' : ''} - ${
+        basicTitle || (basicTitle = document.title)
+      }`;
+      this.name = [name, meta.version]::trueJoin(', ');
+      this.descr = getLocaleString(meta, 'description');
+      this.lists = objectPick(meta, [
         'antifeature',
         'grant',
         'match',
@@ -196,13 +227,17 @@ export default {
         .join('\n')
         || ''
       ));
-      this.lists.require = [...new Set(script.require)];
-      this.lists.resource = [...new Set(urls)];
-      this.meta = script;
+      this.script = { meta, custom: {}, props: {} };
+      this.allDeps = [
+        [...new Set(meta.require)],
+        [...new Set(Object.values(meta.resources))],
+      ];
     },
     async loadDeps() {
-      if (!this.safeIcon) loadScriptIcon(this);
-      const { require, resource } = this.lists;
+      const { script, allDeps: [require, resource] } = this;
+      if (!this.safeIcon) {
+        loadScriptIcon(script).then(url => { this.safeIcon = url; });
+      }
       if (this.require
           && deepEqual(require.slice().sort(), Object.keys(this.require).sort())
           && deepEqual(resource.slice().sort(), Object.keys(this.resources).sort())) {
@@ -224,11 +259,13 @@ export default {
       const download = async (url, target, isBlob) => {
         const fullUrl = getFullUrl(url, this.info.url);
         try {
-          target[fullUrl] = await this.getFile(fullUrl, { isBlob, useCache: true });
-          this.urlsOK[url] = true;
+          const file = await this.getFile(fullUrl, { isBlob, useCache: true });
+          target[fullUrl] = file;
+          this.deps[url] = file;
           finished += 1;
           updateStatus();
         } catch (e) {
+          this.deps[url] = false;
           return url;
         }
       };
@@ -320,7 +357,7 @@ export default {
       if (value) options.set('trackLocalFile', false);
     },
     async checkSameCode() {
-      const { name, namespace } = this.meta;
+      const { name, namespace } = this.script.meta || {};
       const old = await sendCmdDirectly('GetScript', { meta: { name, namespace } });
       this.reinstall = !!old;
       this.sameCode = old && this.code === await sendCmdDirectly('GetScriptCode', old.props.id);
@@ -356,6 +393,9 @@ $infoIconSize: 18px;
   p {
     margin-top: 1rem;
   }
+  .self-start {
+    align-self: flex-start;
+  }
   .image {
     flex: 0 0 $imgSize;
     align-items: center;
@@ -365,6 +405,7 @@ $infoIconSize: 18px;
     box-sizing: content-box;
     img {
       max-width: 100%;
+      max-height: 100%;
     }
   }
   .info {
@@ -377,12 +418,12 @@ $infoIconSize: 18px;
       position: absolute;
       margin-left: calc(-1 * $imgSize / 2 - $infoIconSize / 2 - $imgGapR);
       cursor: pointer;
-      .icon {
-        width: $infoIconSize;
-        height: $infoIconSize;
-      }
     }
   }
+  .icon {
+    width: $infoIconSize;
+    height: $infoIconSize;
+  }
   .lists {
     margin-top: 1rem;
     dl {
@@ -392,6 +433,7 @@ $infoIconSize: 18px;
         background: rgba(255, 0, 0, .05);
         margin-top: -3px;
         padding: 2px 6px;
+        max-width: 25em;
       }
     }
     dt {
@@ -403,9 +445,6 @@ $infoIconSize: 18px;
       max-height: 10vh;
       min-height: 1.5rem;
       overflow-y: auto;
-      a:not([data-ok]) {
-        color: var(--fill-8);
-      }
     }
   }
   [data-collapsed] {
@@ -430,9 +469,14 @@ $infoIconSize: 18px;
   }
   .actions {
     align-items: center;
-    margin: .5rem 0;
-    > #confirm:not(:disabled) {
-      font-weight: bold;
+    label {
+      align-items: center;
+    }
+    .status {
+      border-left: 5px solid darkorange;
+      padding: .5em;
+      color: #d33a00;
+      animation: fade-in .5s 1 both;
     }
   }
   .incognito {
@@ -440,6 +484,7 @@ $infoIconSize: 18px;
     color: red;
   }
   #confirm {
+    font-weight: bold;
     background: #d4e2d4;
     border-color: #75a775;
     color: darkgreen;
@@ -485,4 +530,20 @@ $infoIconSize: 18px;
     width: 13rem;
   }
 }
+.vl-tooltip-bottom {
+  > i {
+    margin-left: 10px;
+  }
+  &.vl-tooltip-align-left {
+    margin-left: -13px;
+  }
+}
+@keyframes fade-in {
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+}
 </style>

+ 0 - 114
src/options/views/edit/externals.vue

@@ -1,114 +0,0 @@
-<template>
-  <div class="edit-externals flex flex-col">
-    <select class="monospace-font" v-model="index"
-            :size="Math.max(2, all.length)"
-            :data-size="all.length">
-      <option v-for="([type, url], i) of all" :key="i"
-              class="ellipsis" v-text="url" :value="i" :data-type="type" />
-    </select>
-    <div class="contents pos-rel flex-auto">
-      <img v-if="img" :src="img">
-      <vm-code v-else class="abs-full"
-               v-model="code" :readonly="true" :mode="mode" ref="code" />
-    </div>
-  </div>
-</template>
-
-<script>
-import VmCode from '#/common/ui/code';
-import storage from '#/common/storage';
-
-export default {
-  props: ['script', 'isRes'],
-  components: { VmCode },
-  data() {
-    const { require, resources } = this.script.meta;
-    return {
-      all: [
-        ...require.map(url => ['@require', url]),
-        ...Object.entries(resources).map(([name, url]) => [`@resource ${name}`, url]),
-      ],
-      code: null,
-      img: null,
-      index: null,
-      mode: null,
-    };
-  },
-  watch: {
-    async index(val) {
-      const [type, url] = this.all[val];
-      const isReq = type === '@require';
-      const key = this.script.custom.pathMap?.[url] || url;
-      let raw = await storage[isReq ? 'require' : 'cache'].getOne(key);
-      if (!isReq) raw = storage.cache.makeDataUri(key, raw);
-      let img;
-      let code;
-      let contentType;
-      if (isReq || !raw) {
-        code = raw;
-      } else if (raw.startsWith('data:image')) {
-        img = raw;
-      } else {
-        [contentType, code] = raw.split(',');
-        if (code == null) { // workaround for bugs in old VM, see 2e135cf7
-          const fileExt = url.match(/\.(\w+)([#&?]|$)/)?.[1] || '';
-          contentType = /^(png|jpe?g|bmp|svgz?|gz|zip)$/i.test(fileExt)
-            ? ''
-            : `text/${fileExt.toLowerCase()}`;
-          code = raw;
-        }
-        code = atob(code);
-        if (/[\x80-\xFF]/.test(code)) {
-          const len = code.length;
-          const bytes = new Uint8Array(len);
-          for (let i = 0; i < len; i += 1) {
-            bytes[i] = code.charCodeAt(i);
-          }
-          code = new TextDecoder().decode(bytes);
-        }
-      }
-      this.img = img;
-      this.mode = contentType === 'text/css' || /\.css([#&?]|$)/i.test(url) ? 'css' : null;
-      this.code = code;
-    },
-  },
-  async mounted() {
-    this.index = 0;
-  },
-};
-</script>
-
-<style>
-.edit-externals {
-  > select {
-    min-height: 1.25rem;
-    max-height: 15vh;
-    padding: 1rem 0;
-    overflow-y: auto;
-    border: solid var(--fill-3);
-    border-width: 2px 0 2px 0;
-    &[data-size="1"] {
-      padding-bottom: 0;
-    }
-    option {
-      padding: 0 1rem;
-      &:checked {
-        font-weight: bold;
-      }
-      &::before {
-        content: attr(data-type) " ";
-        font-style: italic;
-        color: var(--fill-8);
-      }
-    }
-  }
-  > .contents {
-    > img {
-      padding: 1rem;
-      max-width: 100%;
-      max-height: 100%;
-      object-fit: contain;
-    }
-  }
-}
-</style>

+ 2 - 2
src/options/views/edit/index.vue

@@ -48,7 +48,7 @@
       <vm-externals
         class="abs-full"
         v-if="nav === 'externals'"
-        :script="script"
+        v-model="script"
       />
       <vm-help
         class="abs-full edit-body"
@@ -70,7 +70,7 @@ import { route, getUnloadSentry } from '#/common/router';
 import { store } from '../../utils';
 import VmSettings from './settings';
 import VmValues from './values';
-import VmExternals from './externals';
+import VmExternals from '#/common/ui/externals';
 import VmHelp from './help';
 
 const CUSTOM_PROPS = {

+ 1 - 1
src/options/views/tab-settings/vm-editor.vue

@@ -58,7 +58,7 @@ export default {
       const opts = {};
       Object.entries({
         ...(await import('codemirror')).defaults,
-        ...(await import('#/common/ui/code')).cmOptions,
+        ...(await import('#/common/ui/code')).default.data().cmDefaults,
         ...options.get('editor'),
       })
       // sort by keys alphabetically to make it more readable