Browse Source

feat: use CodeMirror for values

+ fix editor regression in dd28779c
tophf 3 years ago
parent
commit
26fc0e85e1

+ 3 - 1
src/background/utils/storage-cache.js

@@ -4,6 +4,7 @@ import { WATCH_STORAGE } from '@/common/consts';
 import { deepCopy, deepCopyDiff, deepSize, forEachEntry } from '@/common/object';
 import { store } from './db';
 import storage, { S_SCRIPT_PRE } from './storage';
+import { clearValueOpener } from './values';
 
 /** Throttling browser API for `storage.value`, processing requests sequentially,
  so that we can supersede an earlier chained request if it's obsolete now,
@@ -127,10 +128,11 @@ window[WATCH_STORAGE] = fn => {
 };
 browser.runtime.onConnect.addListener(port => {
   if (!port.name.startsWith(WATCH_STORAGE)) return;
-  const { id, cfg } = JSON.parse(port.name.slice(WATCH_STORAGE.length));
+  const { id, cfg, tabId } = JSON.parse(port.name.slice(WATCH_STORAGE.length));
   const fn = id ? watchers[id] : port.postMessage.bind(port);
   watchStorage(fn, cfg);
   port.onDisconnect.addListener(() => {
+    clearValueOpener(tabId || port.sender.tab.id);
     watchStorage(fn, cfg, false);
     delete watchers[id];
   });

+ 7 - 2
src/background/utils/values.js

@@ -11,6 +11,11 @@ let chain = Promise.resolve();
 let toSend = {};
 
 Object.assign(commands, {
+  async GetValueStore(id, { tab }) {
+    const frames = nest(nest(openers, id), tab.id);
+    const values = frames[0] || (frames[0] = await storage.value.getOne(id));
+    return values;
+  },
   /**
    * @param {Object} data - key can be an id or a uri
    * @return {Promise<void>}
@@ -34,7 +39,7 @@ Object.assign(commands, {
     const values = objectGet(openers, [id, tab.id, frameId]);
     if (values) { // preventing the weird case of message arriving after the page navigated
       if (raw) values[key] = raw; else delete values[key];
-      nest(toSend, id)[key] = raw || null;
+      if (tab.id >= 0) nest(toSend, id)[key] = raw || null;
       commit({ [id]: values });
       return chain;
     }
@@ -64,7 +69,7 @@ export function clearValueOpener(tabId, frameId) {
 /**
  * @param {number} tabId
  * @param {number} frameId
- * @param {VMInjection.Script[]}
+ * @param {VMInjection.Script[]} injectedScripts
  */
 export function addValueOpener(tabId, frameId, injectedScripts) {
   injectedScripts.forEach(script => {

+ 4 - 4
src/common/index.js

@@ -58,7 +58,6 @@ const COMMANDS_WITH_SRC = [
   'TabClose',
   'TabFocus',
   'TabOpen',
-  'UpdateValue',
 /*
   These are used only by content scripts where sendCmdDirectly can't be used anyway
   'GetInjected',
@@ -74,10 +73,11 @@ const getBgPage = () => browser.extension.getBackgroundPage?.();
  * Sends the command+data directly so it's synchronous and faster than sendCmd thanks to deepCopy.
  * WARNING! Make sure `cmd` handler doesn't use `src` or `cmd` is listed in COMMANDS_WITH_SRC.
  */
-export function sendCmdDirectly(cmd, data, options) {
+export function sendCmdDirectly(cmd, data, options, fakeSrc) {
   const bg = !COMMANDS_WITH_SRC.includes(cmd) && getBgPage();
-  return bg && bg !== window && bg.deepCopy
-    ? bg.handleCommandMessage(bg.deepCopy({ cmd, data })).then(deepCopy)
+  const bgCopy = bg && bg !== window && bg.deepCopy;
+  return bgCopy
+    ? bg.handleCommandMessage(bgCopy({ cmd, data }), fakeSrc && bgCopy(fakeSrc)).then(deepCopy)
     : sendCmd(cmd, data, options);
 }
 

+ 27 - 11
src/common/ui/code.vue

@@ -106,6 +106,7 @@ const cmDefaults = {
    * and is big enough to include most of popular minified libraries for the `@resource/@require` viewer. */
   maxDisplayLength: 100_000,
 };
+const cmCommands = CodeMirror.commands;
 
 export default {
   props: {
@@ -158,8 +159,12 @@ export default {
     mode(value) {
       this.cm.setOption('mode', value || cmDefaults.mode);
     },
-    value(value) {
-      const hasLongLines = new RegExp(`^\\s*.{${maxDisplayLength},}`, 'm').test(value);
+    value: 'updateValue',
+  },
+  methods: {
+    updateValue(value = this.value) {
+      const hasLongLines = value.length > maxDisplayLength
+        && new RegExp(`^\\s*.{${maxDisplayLength},}`, 'm').test(value);
       const { cm } = this;
       if (!cm) return;
       if (hasLongLines) {
@@ -167,8 +172,7 @@ export default {
         this.createPlaceholders({ text: lines, from: { line: 0 } });
         value = lines.join('\n');
       }
-      cm.off('beforeChange', this.onBeforeChange);
-      cm.off('changes', this.onChanges);
+      this.watchCM(false);
       cm.operation(() => {
         cm.setValue(value);
         if (hasLongLines) this.renderPlaceholders();
@@ -176,11 +180,13 @@ export default {
       cm.clearHistory();
       cm.markClean();
       cm.focus();
-      cm.on('changes', this.onChanges);
-      cm.on('beforeChange', this.onBeforeChange);
+      this.watchCM(true);
+    },
+    watchCM(onOff) {
+      onOff = onOff ? 'on' : 'off';
+      this.cm[onOff]('changes', this.onChanges);
+      this.cm[onOff]('beforeChange', this.onBeforeChange);
     },
-  },
-  methods: {
     onBeforeChange(cm, change) {
       if (this.createPlaceholders(change)) {
         cm.on('change', this.onChange); // triggered before DOM is updated
@@ -261,8 +267,9 @@ export default {
         replace: () => this.replace(),
         replaceAll: () => this.replace(1),
       }, this.commands);
-      const { insertTab, insertSoftTab } = CodeMirror.commands;
-      Object.assign(CodeMirror.commands, cm.state.commands, {
+      const cmOrigCommands = {};
+      const { insertTab, insertSoftTab } = cmCommands;
+      for (const cmds of [cm.state.commands, {
         autocomplete() {
           cm.showHint({ hint: CodeMirror.hint.autoHintWithFallback });
         },
@@ -281,7 +288,13 @@ export default {
           (cm.options.indentWithTabs ? insertTab : insertSoftTab)(cm);
         },
         showHelp: this.commands?.showHelp,
-      });
+      }]) {
+        cmds::forEachEntry(([key, val]) => {
+          cmOrigCommands[key] = cmCommands[key];
+          cmCommands[key] = val;
+        });
+      }
+      this.origCommands = cmOrigCommands;
       // these are active in all nav tabs
       cm.setOption('extraKeys', {
         Esc: 'cancel',
@@ -304,6 +317,8 @@ export default {
       cm.on('keyHandled', (_cm, _name, e) => {
         e.stopPropagation();
       });
+      if (this.value) this.updateValue();
+      else this.watchCM(true);
       this.$emit('ready', cm);
     },
     onActive(state) {
@@ -574,6 +589,7 @@ export default {
     });
   },
   beforeDestroy() {
+    Object.assign(cmCommands, this.origCommands);
     this.onActive(false);
   },
 };

+ 97 - 55
src/options/views/edit/values.vue

@@ -47,23 +47,24 @@
          @keydown.up.exact="onUpDown"
          v-if="trash">
       <!-- eslint-disable-next-line vue/no-unused-vars -->
-      <div v-for="([key, val, displayVal, len], trashKey) in trash" :key="trashKey"
+      <div v-for="({ key, cut, len }, trashKey) in trash" :key="trashKey"
            class="edit-values-row flex"
            @click="onRestore(trashKey)">
         <a class="ellipsis" v-text="key" tabindex="0"/>
-        <s class="ellipsis flex-auto" v-text="displayVal"/>
+        <s class="ellipsis flex-auto" v-text="cut"/>
         <pre v-text="len"/>
       </div>
     </div>
-    <div class="edit-values-empty mt-1" v-if="!keys.length" v-text="i18n('noValues')"/>
+    <div class="edit-values-empty mt-1" v-if="!loading && !keys.length" v-text="i18n('noValues')"/>
     <div class="edit-values-panel flex flex-col mb-1c" v-if="current">
       <div class="control">
         <h4 v-text="current.isAll ? i18n('labelEditValueAll') : i18n('labelEditValue')"/>
         <div>
           <button v-text="i18n('editValueSave')" @click="onSave"
+                  class="save"
                   :class="{'has-error': current.error}"
                   :title="current.error"
-                  :disabled="current.error || current.value === initial"/>
+                  :disabled="current.error || !current.dirty"/>
           <button v-text="i18n('editValueCancel')" @click="onCancel"></button>
         </div>
       </div>
@@ -77,12 +78,12 @@
       <label>
         <span v-text="current.isAll ? i18n('valueLabelValueAll') : i18n('valueLabelValue')"/>
         <!-- TODO: use CodeMirror in json mode -->
-        <textarea v-model="current.value"
-                  ref="value"
-                  class="h100 monospace-font"
-                  spellcheck="false"
-                  @input="onChange"
-                  @keydown.esc.exact.stop="onCancel"/>
+        <vm-code v-model="current.value"
+                 ref="value"
+                 class="h-100 mt-1"
+                 mode="application/json"
+                 @code-dirty="onChange"
+                 :commands="{ close: onCancel, save: onSave }"/>
       </label>
     </div>
   </div>
@@ -93,6 +94,7 @@ import { dumpScriptValue, formatByteLength, isEmpty, sendCmdDirectly } from '@/c
 import { handleTabNavigation, keyboardService } from '@/common/keyboard';
 import { deepCopy, deepEqual, mapEntry } from '@/common/object';
 import { WATCH_STORAGE } from '@/common/consts';
+import VmCode from '@/common/ui/code';
 import Icon from '@/common/ui/icon';
 import { showMessage } from '@/common/ui';
 import { store } from '../../utils';
@@ -101,7 +103,8 @@ const PAGE_SIZE = 25;
 const MAX_LENGTH = 1024;
 const MAX_JSON_DURATION = 10; // ms
 let focusedElement;
-
+const currentObservables = { error: '', dirty: false };
+const cutLength = s => (s.length > MAX_LENGTH ? s.slice(0, MAX_LENGTH) : s);
 const reparseJson = (str) => {
   try {
     return JSON.stringify(JSON.parse(str), null, '  ');
@@ -114,16 +117,20 @@ const getActiveElement = () => document.activeElement;
 const flipPage = (vm, dir) => {
   vm.page = Math.max(1, Math.min(vm.totalPages, vm.page + dir));
 };
+/** Uses a negative tabId which is recognized in bg::values.js */
+const fakeSender = () => ({ tab: { id: Math.random() - 2 }, frameId: 0 });
 const conditionNotEdit = { condition: '!edit' };
 
 export default {
   props: ['active', 'script'],
   components: {
     Icon,
+    VmCode,
   },
   data() {
     return {
       current: null,
+      loading: true,
       page: null,
       values: null,
       trash: null,
@@ -148,12 +155,15 @@ export default {
   },
   watch: {
     active(val) {
+      const id = this.script.props.id;
       if (val) {
-        (this.current ? this.$refs.value : focusedElement)?.focus();
-        sendCmdDirectly('Storage', ['value', 'getOne', this.script.props.id]).then(data => {
-          if (!this.values && this.setData(data) && this.keys.length) {
+        (this.current ? this.cm : focusedElement)?.focus();
+        sendCmdDirectly('GetValueStore', id, undefined, this.sender = fakeSender()).then(data => {
+          const isFirstTime = !this.values;
+          if (this.setData(data) && isFirstTime && this.keys.length) {
             this.autofocus(true);
           }
+          this.loading = false;
         });
         this.disposeList = [
           keyboardService.register('pageup', () => flipPage(this, -1), conditionNotEdit),
@@ -168,8 +178,9 @@ export default {
         const bg = browser.extension.getBackgroundPage();
         this[WATCH_STORAGE] = browser.runtime.connect({
           name: WATCH_STORAGE + JSON.stringify({
-            cfg: { value: this.script.props.id },
+            cfg: { value: id },
             id: bg?.[WATCH_STORAGE](fn),
+            tabId: this.sender.tab.id,
           }),
         });
         if (!bg) this[WATCH_STORAGE].onMessage.addListener(fn);
@@ -181,11 +192,22 @@ export default {
     current(val, oldVal) {
       if (val) {
         focusedElement = getActiveElement();
-        this.initial = val.value;
         this.$nextTick(() => {
-          const el = this.$refs[val.isNew ? 'key' : 'value'];
-          el.setSelectionRange(0, 0);
-          el.focus();
+          const refs = this.$refs;
+          const vmCode = refs.value;
+          const { cm } = vmCode;
+          this.cm = cm;
+          if (oldVal) {
+            vmCode.updateValue(val.value); // focuses CM, which we may override in isNew below
+          }
+          if (val.isNew) {
+            const el = refs.key;
+            el.setSelectionRange(0, 0);
+            el.focus();
+          } else {
+            cm.setCursor(0, 0);
+            cm.focus();
+          }
         });
       } else if (oldVal) {
         focusedElement?.focus();
@@ -202,20 +224,17 @@ export default {
         this.$refs.editAll[andClick ? 'click' : 'focus']();
       });
     },
-    getLength(key) {
-      const len = this.values[key].length - 1;
+    getLength(key, raw) {
+      const len = (this.values[key] || raw).length - 1;
       return len < 10_000 ? len : formatByteLength(len);
     },
-    getValue(key, sliced) {
-      let value = this.values[key];
+    getValue(key, sliced, raw) {
+      let value = this.values[key] || raw;
       const type = value[0];
       value = value.slice(1);
       if (type === 's') value = JSON.stringify(value);
       else if (!sliced) value = reparseJson(value);
-      if (sliced && value.length > MAX_LENGTH) {
-        value = value.slice(0, MAX_LENGTH);
-      }
-      return value;
+      return sliced ? cutLength(value) : value;
     },
     getValueAll() {
       return `{\n  ${
@@ -243,7 +262,7 @@ export default {
       rawValue = dumpScriptValue(jsonValue) || '',
     }) {
       const { id } = this.script.props;
-      return sendCmdDirectly('UpdateValue', { id, key, raw: rawValue })
+      return sendCmdDirectly('UpdateValue', { id, key, raw: rawValue }, undefined, this.sender)
       .then(() => {
         if (rawValue) {
           this.$set(this.values, key, rawValue);
@@ -258,38 +277,40 @@ export default {
         isNew: true,
         key: '',
         value: '',
+        ...currentObservables,
       };
     },
     async onRemove(key) {
-      (this.trash || (this.trash = {}))[key + Math.random()] = [
+      this.updateValue({ key });
+      (this.trash || (this.trash = {}))[key + Math.random()] = {
         key,
-        this.values[key],
-        this.getValue(key, true),
-        this.getLength(key),
-      ];
-      await this.updateValue({ key });
+        rawValue: this.values[key],
+        cut: this.getValue(key, true),
+        len: this.getLength(key),
+      };
       if (this.current?.key === key) {
         this.current = null;
       }
     },
     onRestore(trashKey) {
       const { trash } = this;
-      const [key, rawValue] = trash[trashKey];
+      const { key, rawValue } = trash[trashKey];
       delete trash[trashKey];
       if (isEmpty(trash)) this.trash = null;
       this.updateValue({ key, rawValue });
     },
     onEdit(key) {
       this.current = {
-        isNew: false,
         key,
         value: this.getValue(key),
+        ...currentObservables,
       };
     },
     onEditAll() {
       this.current = {
         isAll: true,
         value: this.getValueAll(),
+        ...currentObservables,
       };
     },
     async onSave() {
@@ -299,9 +320,10 @@ export default {
         this.onChange();
       }
       if (current.error) {
-        const pos = +current.error.match(/position\s+(\d+)|$/)[1] || 0;
-        this.$refs.value.setSelectionRange(pos, pos + 1);
-        this.$refs.value.focus();
+        const { cm } = this;
+        const pos = current.errorPos;
+        cm.setSelection(pos, { line: pos.line, ch: pos.ch + 1 });
+        cm.focus();
         showMessage({ text: current.error });
         return;
       }
@@ -315,18 +337,35 @@ export default {
       }
     },
     onCancel() {
+      const cur = this.current;
+      if (cur.dirty) {
+        const key = `${cur.key} ${Math.random() * 1e9 | 0}`;
+        const val = this.cm.getValue();
+        const rawValue = dumpScriptValue(val);
+        (this.trash || (this.trash = {}))[key] = {
+          key,
+          rawValue,
+          cut: cutLength(val),
+          len: this.getLength(key, rawValue),
+        };
+      }
       this.current = null;
     },
-    onChange() {
+    onChange(isChanged) {
       const { current } = this;
+      current.dirty = isChanged;
       current.error = null;
       if (current.jsonPaused) return;
+      const { cm } = this;
       const t0 = performance.now();
-      const str = current.value.trim();
       try {
-        current.jsonValue = str ? JSON.parse(str) : undefined;
+        const str = cm.getValue();
+        current.jsonValue = str.trim() ? JSON.parse(str) : undefined;
       } catch (e) {
-        current.error = e.message || e;
+        const re = /(position\s+)(\d+)|$/;
+        const pos = cm.posFromIndex(+`${e}`.match(re)[2] || 0);
+        current.error = `${e}`.replace(re, `$1${pos.line + 1}:${pos.ch + 1}`);
+        current.errorPos = pos;
         current.jsonValue = undefined;
       }
       current.jsonPaused = performance.now() - t0 > MAX_JSON_DURATION;
@@ -336,12 +375,12 @@ export default {
       if (data) {
         const { current } = this;
         const currentKey = current?.key;
-        const valueGetter = current && (currentKey ? this.getValue : this.getValueAll);
+        const valueGetter = current && (current.isAll ? this.getValueAll : this.getValue);
         const oldText = valueGetter && valueGetter(currentKey);
         this.setData(data instanceof Object ? data : deepCopy(data));
         if (current) {
           const newText = valueGetter(currentKey);
-          const curText = current.value;
+          const curText = this.cm.getValue();
           if (curText === newText) {
             current.isNew = false;
           } else if (curText === oldText) {
@@ -367,9 +406,11 @@ export default {
 </script>
 
 <style>
+$lightBorder: 1px solid var(--fill-2);
+
 .edit-values {
   &-row {
-    border: 1px solid var(--fill-2);
+    border: $lightBorder;
     cursor: pointer;
     .main > &:first-child {
       padding: 8px 6px;
@@ -386,7 +427,7 @@ export default {
         max-width: 240px;
       }
       &:not(:first-child) {
-        border-left: 1px solid var(--fill-2);
+        border-left: $lightBorder;
       }
     }
     pre {
@@ -442,21 +483,22 @@ export default {
     label {
       display: flex;
       flex-direction: column;
-      &:last-child,
-      &:last-child textarea {
+      &:last-child {
         flex: auto;
         height: 0;
       }
-      > textarea, input {
+      > input {
         margin: .25em 0;
         padding: .25em;
       }
     }
-    textarea {
-      width: 100%;
-      word-break: break-all;
-      resize: none;
-    }
+  }
+  .save:not([disabled]) {
+    background-color: gold;
+    color: #000;
+  }
+  .CodeMirror {
+    border: $lightBorder;
   }
 }
 </style>