From a480a799987ae4c30ed0bb4654aec072b6f11688 Mon Sep 17 00:00:00 2001
From: Fabio Manganiello <blacklight86@gmail.com>
Date: Sat, 13 Jun 2020 17:28:50 +0200
Subject: [PATCH] Split options page into components

---
 .gitignore                     |   1 +
 src/common.scss                |  52 ++++++
 src/options/App.vue            | 318 +++------------------------------
 src/options/EditHost.vue       |  46 +++++
 src/options/LocalCommands.vue  |  16 ++
 src/options/NewHost.vue        |  42 +++++
 src/options/RemoteCommands.vue |  16 ++
 src/options/Run.vue            | 159 +++++++++++++++++
 src/options/options.js         |   1 +
 src/utils.js                   |  56 ++++++
 10 files changed, 410 insertions(+), 297 deletions(-)
 create mode 100644 src/common.scss
 create mode 100644 src/options/EditHost.vue
 create mode 100644 src/options/LocalCommands.vue
 create mode 100644 src/options/NewHost.vue
 create mode 100644 src/options/RemoteCommands.vue
 create mode 100644 src/options/Run.vue

diff --git a/.gitignore b/.gitignore
index 8e33e27..c680358 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@
 /*.log
 /dist
 /dist-zip
+Session.vim
diff --git a/src/common.scss b/src/common.scss
new file mode 100644
index 0000000..388ba73
--- /dev/null
+++ b/src/common.scss
@@ -0,0 +1,52 @@
+html,
+body {
+  font-size: 14px;
+  font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, 'Helvetica Neue', sans-serif;
+}
+
+a,
+a:visited {
+  color: initial;
+  text-decoration: underline dotted #888;
+}
+
+a:hover {
+  opacity: 0.7;
+}
+
+h2 {
+  font-size: 1.2em;
+  margin-bottom: 0.75em;
+  padding-bottom: 0.75em;
+  border-bottom: 1px solid rgba(0, 0, 0, 0.1);
+}
+
+form {
+  input[type='text'] {
+    display: block;
+    margin-bottom: 0.5em;
+    border-radius: 1em;
+    padding: 0.4em;
+    border: 1px solid rgba(0, 0, 0, 0.2);
+
+    &:hover {
+      border: 1px solid rgba(40, 235, 70, 0.3);
+    }
+
+    &:focus {
+      border: 1px solid rgba(40, 235, 70, 0.7);
+    }
+  }
+
+  .buttons {
+    margin-top: 0.5em;
+    padding-top: 0.5em;
+    border-top: 1px solid rgba(0, 0, 0, 0.15);
+
+    button {
+      margin-right: 0.3em;
+    }
+  }
+}
+
+// vim:sw=2:ts=2:et:
diff --git a/src/options/App.vue b/src/options/App.vue
index 846b927..6fdda5c 100644
--- a/src/options/App.vue
+++ b/src/options/App.vue
@@ -11,97 +11,12 @@
     />
 
     <div class="body">
-      <div class="page add" v-if="isAddHost">
-        <h2>Add a new device</h2>
-        <form class="host-form" ref="addHostForm" @submit.prevent="addHost">
-          <input type="text" name="name" placeholder="Name" autocomplete="off" :disabled="loading" />
-          <input type="text" name="address" placeholder="IP or hostname" @keyup="onAddrChange($refs.addHostForm)" autocomplete="off" :disabled="loading" />
-          <input type="text" name="port" value="8008" placeholder="HTTP port" @keyup="onPortChange($refs.addHostForm)" autocomplete="off" :disabled="loading" />
-          <input type="text" name="websocketPort" value="8009" placeholder="Websocket port" autocomplete="off" :disabled="loading" />
-          <input type="text" name="token" placeholder="Access token" autocomplete="off" :disabled="loading" />
-          <div class="row ssl">
-            <input type="checkbox" name="ssl" :disabled="loading" />
-            <label for="ssl">Use SSL</label>
-          </div>
-
-          <div class="buttons">
-            <input type="submit" value="Add" :disabled="loading" />
-          </div>
-        </form>
-      </div>
-
-      <div class="page local-procedures" v-else-if="selectedHostOption === 'localProc'">
-        <h2>Procedures stored on browser</h2>
-      </div>
-
-      <div class="page remote-procedures" v-else-if="selectedHostOption === 'remoteProc'">
-        <h2>Procedures stored on server</h2>
-      </div>
-
-      <div class="page run" v-else-if="selectedHostOption === 'run'">
-        <h2>Run a command on {{ hosts[selectedHost].name }}</h2>
-        <form class="run-form" ref="runForm" @submit.prevent="runAction">
-          <div class="row action-name">
-            <input type="text" name="action" v-model="action.name" placeholder="Action" autocomplete="off" :disabled="loading" />
-            <span class="help">
-              &nbsp; <a href="https://platypush.readthedocs.io/en/latest/plugins.html" target="_blank">Plugins reference</a>. Use <tt>$URL$</tt> as argument value to denote the
-              current URL.
-            </span>
-          </div>
-
-          <div class="row" v-for="(arg, i) in action.args" :key="i">
-            <div class="label">
-              <input type="text" :name="'arg' + i" v-model="arg.name" placeholder="Name" autocomplete="off" :disabled="loading" />
-            </div>
-
-            <div class="value">
-              <input type="text" :name="arg.name" v-model="arg.value" data-type="argument" placeholder="Value" autocomplete="off" :disabled="loading" />
-              <button type="button" @click="action.args.splice(i, 1)" :disabled="loading"><i class="fas fa-trash" /></button>
-            </div>
-          </div>
-
-          <div class="row buttons">
-            <button type="button" @click="addActionArgument" :disabled="loading"><i class="fas fa-plus" /> &nbsp; Add Argument</button>
-            <button type="button" @click="clearAction" :disabled="loading"><i class="fas fa-times" /> &nbsp; Clear Form</button>
-            <button type="submit" :disabled="loading"><i class="fas fa-play" /> &nbsp; Run</button>
-          </div>
-        </form>
-
-        <div class="code response" v-text="actionResponse" v-if="actionResponse && (actionResponse.length || Object.keys(actionResponse).length)" />
-        <div class="code error" v-text="actionError" v-if="actionError && actionError.length" />
-      </div>
-
-      <div class="page edit" v-else-if="selectedHost >= 0">
-        <h2>Edit device {{ hosts[selectedHost].name }}</h2>
-        <form class="host-form" ref="editHostForm" @submit.prevent="editHost">
-          <input type="text" name="name" placeholder="Name" :value="hosts[selectedHost].name" autocomplete="off" :disabled="loading" />
-          <input type="text" name="address" placeholder="IP or hostname" :value="hosts[selectedHost].address" autocomplete="off" :disabled="loading" />
-          <input
-            type="text"
-            name="port"
-            placeholder="HTTP port"
-            autocomplete="off"
-            :value="hosts[selectedHost].port"
-            @keyup="onPortChange($refs.editHostForm)"
-            :disabled="loading"
-          />
-          <input type="text" name="websocketPort" :value="hosts[selectedHost].websocketPort" placeholder="Websocket port" autocomplete="off" :disabled="loading" />
-          <input type="text" name="token" placeholder="Access token" :value="hosts[selectedHost].token" autocomplete="off" :disabled="loading" />
-          <div class="row ssl">
-            <input type="checkbox" name="ssl" v-model="hosts[selectedHost].ssl" :disabled="loading" />
-            <label for="ssl">Use SSL</label>
-          </div>
-
-          <div class="buttons">
-            <input type="submit" value="Edit" :disabled="loading" />
-            <button type="button" @click="removeHost" :disabled="loading">Remove</button>
-          </div>
-        </form>
-      </div>
-
-      <div class="none" v-else>
-        Select an option from the menu
-      </div>
+      <NewHost @add="addHost" v-if="isAddHost" />
+      <LocalCommands v-else-if="selectedHost >= 0 && selectedHostOption === 'localProc'" />
+      <RemoteCommands v-else-if="selectedHost >= 0 && selectedHostOption === 'remoteProc'" />
+      <Run :host="hosts[selectedHost]" v-else-if="selectedHost >= 0 && selectedHostOption === 'run'" />
+      <EditHost :host="hosts[selectedHost]" @save="editHost" @remove="removeHost" v-else-if="selectedHost >= 0" />
+      <div class="none" v-else>Select an option from the menu</div>
     </div>
   </div>
 </template>
@@ -109,25 +24,29 @@
 <script>
 import mixins from '../utils';
 import Menu from './Menu';
+import NewHost from './NewHost';
+import EditHost from './EditHost';
+import LocalCommands from './LocalCommands';
+import RemoteCommands from './RemoteCommands';
+import Run from './Run';
 
 export default {
   name: 'App',
   mixins: [mixins],
-  components: { Menu },
+  components: {
+    Menu,
+    NewHost,
+    EditHost,
+    LocalCommands,
+    RemoteCommands,
+    Run,
+  },
 
   data() {
     return {
-      hosts: [],
       selectedHost: -1,
       selectedHostOption: null,
       isAddHost: false,
-      loading: false,
-      actionResponse: null,
-      actionError: null,
-      action: {
-        name: null,
-        args: [],
-      },
     };
   },
 
@@ -147,59 +66,7 @@ export default {
       this.isAddHost = true;
     },
 
-    addActionArgument() {
-      this.action.args.push({
-        name: '',
-        value: '',
-      });
-    },
-
-    onAddrChange(form) {
-      if (form.name.value.length && !form.address.value.startsWith(form.name.value)) {
-        return;
-      }
-
-      form.name.value = form.address.value;
-    },
-
-    onPortChange(form) {
-      const port = form.port.value;
-      if (!this.isPortValid(port)) return;
-      form.websocketPort.value = '' + (parseInt(port) + 1);
-    },
-
-    isPortValid(port) {
-      port = parseInt(port);
-      return !isNaN(port) && port > 0 && port < 65536;
-    },
-
-    isHostFormValid(form) {
-      return form.name.value.length && form.address.value.length && this.isPortValid(form.port.value) && this.isPortValid(form.websocketPort.value);
-    },
-
-    clearAction() {
-      this.action.name = null;
-      this.action.args = [];
-      this.actionResponse = null;
-      this.actionError = null;
-    },
-
-    async runAction() {
-      this.loading = true;
-
-      try {
-        this.actionResponse = await this.run(this.action, this.hosts[this.selectedHost]);
-        this.actionError = null;
-      } catch (e) {
-        this.actionResponse = null;
-        this.actionError = e.toString();
-      } finally {
-        this.loading = false;
-      }
-    },
-
-    async addHost() {
-      const form = this.$refs.addHostForm;
+    async addHost(form) {
       if (!this.isHostFormValid(form)) {
         this.notify('Invalid device parameter values', 'Device configuration error');
         return;
@@ -224,8 +91,7 @@ export default {
       }
     },
 
-    async editHost() {
-      const form = this.$refs.editHostForm;
+    async editHost(form) {
       if (!this.isHostFormValid(form)) {
         this.notify('Invalid device parameter values', 'Device configuration error');
         return;
@@ -265,32 +131,6 @@ export default {
         this.loading = false;
       }
     },
-
-    async loadHosts() {
-      this.loading = true;
-
-      try {
-        const response = await browser.storage.local.get('hosts');
-        this.hosts = JSON.parse(response.hosts);
-      } finally {
-        this.loading = false;
-      }
-    },
-
-    async saveHosts() {
-      await browser.storage.local.set({ hosts: JSON.stringify(this.hosts) });
-    },
-
-    formToHost(form) {
-      return {
-        name: form.name.value,
-        address: form.address.value,
-        port: parseInt(form.port.value),
-        websocketPort: parseInt(form.websocketPort.value),
-        ssl: form.ssl.checked,
-        token: form.token.value,
-      };
-    },
   },
 
   created() {
@@ -303,25 +143,6 @@ export default {
 .container {
   display: flex;
   height: 100vh;
-  font-size: 14px;
-  font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Ubuntu, 'Helvetica Neue', sans-serif;
-}
-
-a,
-a:visited {
-  color: initial;
-  text-decoration: underline dotted #888;
-}
-
-a:hover {
-  opacity: 0.7;
-}
-
-h2 {
-  font-size: 1.2em;
-  margin-bottom: 0.75em;
-  padding-bottom: 0.75em;
-  border-bottom: 1px solid rgba(0, 0, 0, 0.1);
 }
 
 .body {
@@ -345,103 +166,6 @@ h2 {
   width: 100%;
   padding: 0 1em;
 }
-
-form {
-  input[type='text'] {
-    display: block;
-    margin-bottom: 0.5em;
-    border-radius: 1em;
-    padding: 0.4em;
-    border: 1px solid rgba(0, 0, 0, 0.2);
-
-    &:hover {
-      border: 1px solid rgba(40, 235, 70, 0.3);
-    }
-
-    &:focus {
-      border: 1px solid rgba(40, 235, 70, 0.7);
-    }
-  }
-
-  .row.ssl {
-    display: flex;
-    align-items: center;
-  }
-
-  .buttons {
-    margin-top: 0.5em;
-    padding-top: 0.5em;
-    border-top: 1px solid rgba(0, 0, 0, 0.15);
-
-    button {
-      margin-right: 0.3em;
-    }
-  }
-}
-
-.run-form {
-  position: relative;
-  max-width: 50em;
-
-  .row {
-    display: flex;
-    align-items: center;
-    margin-bottom: 0.5em;
-    padding-bottom: 0.5em;
-  }
-
-  .label {
-    width: 30%;
-    input[type='text'] {
-      width: 90%;
-    }
-  }
-
-  .value {
-    width: 70%;
-    input[type='text'] {
-      width: 80%;
-    }
-
-    button {
-      background: white;
-      padding: 0.25em 1.5em;
-      margin-left: 0.5em;
-      border: 1px solid rgba(0, 0, 0, 0.3);
-      border-radius: 1em;
-
-      &:hover {
-        opacity: 0.8;
-      }
-    }
-  }
-
-  input {
-    display: inline-flex !important;
-    margin-bottom: 0 !important;
-  }
-
-  [type='submit'] {
-    position: absolute;
-    right: 0.9em;
-  }
-}
-
-.code {
-  padding: 1em;
-  white-space: pre-wrap;
-  font-family: monospace;
-  border: 1px dotted rgba(0, 0, 0, 0.8);
-  border-radius: 1em;
-
-  &.response {
-    background: rgba(200, 255, 200, 0.3);
-  }
-
-  &.error {
-    background: rgba(255, 200, 200, 0.3);
-  }
-}
 </style>
 
 <!-- vim:sw=2:ts=2:et: -->
diff --git a/src/options/EditHost.vue b/src/options/EditHost.vue
new file mode 100644
index 0000000..2c96c4f
--- /dev/null
+++ b/src/options/EditHost.vue
@@ -0,0 +1,46 @@
+<template>
+  <div class="page edit">
+    <h2>Edit device {{ host.name }}</h2>
+    <form class="host-form" ref="form" @submit.prevent="$emit('save', $event.target)">
+      <input type="text" name="name" placeholder="Name" :value="host.name" autocomplete="off" :disabled="loading" />
+      <input type="text" name="address" placeholder="IP or hostname" :value="host.address" autocomplete="off" :disabled="loading" />
+      <input type="text" name="port" placeholder="HTTP port" autocomplete="off" :value="host.port" :disabled="loading" @keyup="onPortChange($refs.form)" />
+      <input type="text" name="websocketPort" :value="host.websocketPort" placeholder="Websocket port" autocomplete="off" :disabled="loading" />
+      <input type="text" name="token" placeholder="Access token" :value="host.token" autocomplete="off" :disabled="loading" />
+      <div class="row ssl">
+        <input type="checkbox" name="ssl" v-model="host.ssl" :disabled="loading" />
+        <label for="ssl">Use SSL</label>
+      </div>
+
+      <div class="buttons">
+        <input type="submit" value="Edit" :disabled="loading" />
+        <button type="button" @click="$emit('remove')" :disabled="loading">Remove</button>
+      </div>
+    </form>
+  </div>
+</template>
+
+<script>
+import mixins from '../utils';
+
+export default {
+  name: 'EditHost',
+  mixins: [mixins],
+  props: {
+    host: Object,
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+form {
+  input[type='text'] {
+    .row.ssl {
+      display: flex;
+      align-items: center;
+    }
+  }
+}
+</style>
+
+<!-- vim:sw=2:ts=2:et: -->
diff --git a/src/options/LocalCommands.vue b/src/options/LocalCommands.vue
new file mode 100644
index 0000000..5019b20
--- /dev/null
+++ b/src/options/LocalCommands.vue
@@ -0,0 +1,16 @@
+<template>
+  <div class="page local-procedures">
+    <h2>Commands stored on the browser</h2>
+  </div>
+</template>
+
+<script>
+import mixins from '../utils';
+
+export default {
+  name: 'LocalCommands',
+  mixins: [mixins],
+};
+</script>
+
+<!-- vim:sw=2:ts=2:et: -->
diff --git a/src/options/NewHost.vue b/src/options/NewHost.vue
new file mode 100644
index 0000000..8e44277
--- /dev/null
+++ b/src/options/NewHost.vue
@@ -0,0 +1,42 @@
+<template>
+  <div class="page add">
+    <h2>Add a new device</h2>
+    <form class="host-form" ref="form" @submit.prevent="$emit('add', $event.target)">
+      <input type="text" name="name" placeholder="Name" autocomplete="off" :disabled="loading" />
+      <input type="text" name="address" placeholder="IP or hostname" @keyup="onAddrChange($refs.form)" autocomplete="off" :disabled="loading" />
+      <input type="text" name="port" value="8008" placeholder="HTTP port" @keyup="onPortChange($refs.form)" autocomplete="off" :disabled="loading" />
+      <input type="text" name="websocketPort" value="8009" placeholder="Websocket port" autocomplete="off" :disabled="loading" />
+      <input type="text" name="token" placeholder="Access token" autocomplete="off" :disabled="loading" />
+      <div class="row ssl">
+        <input type="checkbox" name="ssl" :disabled="loading" />
+        <label for="ssl">Use SSL</label>
+      </div>
+
+      <div class="buttons">
+        <input type="submit" value="Add" :disabled="loading" />
+      </div>
+    </form>
+  </div>
+</template>
+
+<script>
+import mixins from '../utils';
+
+export default {
+  name: 'NewHost',
+  mixins: [mixins],
+};
+</script>
+
+<style lang="scss" scoped>
+form {
+  input[type='text'] {
+    .row.ssl {
+      display: flex;
+      align-items: center;
+    }
+  }
+}
+</style>
+
+<!-- vim:sw=2:ts=2:et: -->
diff --git a/src/options/RemoteCommands.vue b/src/options/RemoteCommands.vue
new file mode 100644
index 0000000..1cedae8
--- /dev/null
+++ b/src/options/RemoteCommands.vue
@@ -0,0 +1,16 @@
+<template>
+  <div class="page remote-procedures">
+    <h2>Procedures stored on the server</h2>
+  </div>
+</template>
+
+<script>
+import mixins from '../utils';
+
+export default {
+  name: 'RemoteCommands',
+  mixins: [mixins],
+};
+</script>
+
+<!-- vim:sw=2:ts=2:et: -->
diff --git a/src/options/Run.vue b/src/options/Run.vue
new file mode 100644
index 0000000..3953230
--- /dev/null
+++ b/src/options/Run.vue
@@ -0,0 +1,159 @@
+<template>
+  <div class="page run">
+    <h2>Run a command on {{ host.name }}</h2>
+    <form @submit.prevent="runAction">
+      <div class="row action-name">
+        <input type="text" name="action" v-model="action.name" placeholder="Action" autocomplete="off" :disabled="loading" />
+        <span class="help">
+          &nbsp; <a href="https://platypush.readthedocs.io/en/latest/plugins.html" target="_blank">Plugins reference</a>. Use <tt>$URL$</tt> as argument value to denote the current
+          URL.
+        </span>
+      </div>
+
+      <div class="row" v-for="(arg, i) in action.args" :key="i">
+        <div class="label">
+          <input type="text" :name="'arg' + i" v-model="arg.name" placeholder="Name" autocomplete="off" :disabled="loading" />
+        </div>
+
+        <div class="value">
+          <input type="text" :name="arg.name" v-model="arg.value" data-type="argument" placeholder="Value" autocomplete="off" :disabled="loading" />
+          <button type="button" @click="action.args.splice(i, 1)" :disabled="loading"><i class="fas fa-trash" /></button>
+        </div>
+      </div>
+
+      <div class="row buttons">
+        <button type="button" @click="addActionArgument" :disabled="loading"><i class="fas fa-plus" /> &nbsp; Add Argument</button>
+        <button type="button" @click="clearAction" :disabled="loading"><i class="fas fa-times" /> &nbsp; Clear Form</button>
+        <button type="submit" :disabled="loading"><i class="fas fa-play" /> &nbsp; Run</button>
+      </div>
+    </form>
+
+    <div class="code response" v-text="actionResponse" v-if="actionResponse && (actionResponse.length || Object.keys(actionResponse).length)" />
+    <div class="code error" v-text="actionError" v-if="actionError && actionError.length" />
+  </div>
+</template>
+
+<script>
+import mixins from '../utils';
+
+export default {
+  name: 'Run',
+  mixins: [mixins],
+  props: {
+    host: Object,
+  },
+
+  data() {
+    return {
+      actionResponse: null,
+      actionError: null,
+      action: {
+        name: null,
+        args: [],
+      },
+    };
+  },
+
+  methods: {
+    clearAction() {
+      this.action.name = null;
+      this.action.args = [];
+      this.actionResponse = null;
+      this.actionError = null;
+    },
+
+    async runAction() {
+      this.loading = true;
+
+      try {
+        this.actionResponse = await this.run(this.action, this.host);
+        this.actionError = null;
+      } catch (e) {
+        this.actionResponse = null;
+        this.actionError = e.toString();
+      } finally {
+        this.loading = false;
+      }
+    },
+
+    addActionArgument() {
+      this.action.args.push({
+        name: '',
+        value: '',
+      });
+    },
+  },
+
+  created() {
+    this.clearAction();
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+form {
+  position: relative;
+  max-width: 50em;
+
+  .row {
+    display: flex;
+    align-items: center;
+    margin-bottom: 0.5em;
+    padding-bottom: 0.5em;
+  }
+
+  .label {
+    width: 30%;
+    input[type='text'] {
+      width: 90%;
+    }
+  }
+
+  .value {
+    width: 70%;
+    input[type='text'] {
+      width: 80%;
+    }
+
+    button {
+      background: white;
+      padding: 0.25em 1.5em;
+      margin-left: 0.5em;
+      border: 1px solid rgba(0, 0, 0, 0.3);
+      border-radius: 1em;
+
+      &:hover {
+        opacity: 0.8;
+      }
+    }
+  }
+
+  input {
+    display: inline-flex !important;
+    margin-bottom: 0 !important;
+  }
+
+  [type='submit'] {
+    position: absolute;
+    right: 0.9em;
+  }
+}
+
+.code {
+  padding: 1em;
+  white-space: pre-wrap;
+  font-family: monospace;
+  border: 1px dotted rgba(0, 0, 0, 0.8);
+  border-radius: 1em;
+
+  &.response {
+    background: rgba(200, 255, 200, 0.3);
+  }
+
+  &.error {
+    background: rgba(255, 200, 200, 0.3);
+  }
+}
+</style>
+
+<!-- vim:sw=2:ts=2:et: -->
diff --git a/src/options/options.js b/src/options/options.js
index fa15a1e..0cdd1e0 100644
--- a/src/options/options.js
+++ b/src/options/options.js
@@ -1,6 +1,7 @@
 import Vue from 'vue';
 import App from './App';
 
+require('../common.scss');
 global.browser = require('webextension-polyfill');
 
 /* eslint-disable no-new */
diff --git a/src/utils.js b/src/utils.js
index e3b078e..03da864 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -1,6 +1,13 @@
 import axios from 'axios';
 
 export default {
+  data() {
+    return {
+      loading: false,
+      hosts: [],
+    };
+  },
+
   methods: {
     notify(message, title) {
       browser.notifications.create({
@@ -51,6 +58,55 @@ export default {
         throw e;
       }
     },
+
+    async loadHosts() {
+      this.loading = true;
+
+      try {
+        const response = await browser.storage.local.get('hosts');
+        this.hosts = JSON.parse(response.hosts);
+      } finally {
+        this.loading = false;
+      }
+    },
+
+    async saveHosts() {
+      await browser.storage.local.set({ hosts: JSON.stringify(this.hosts) });
+    },
+
+    formToHost(form) {
+      return {
+        name: form.name.value,
+        address: form.address.value,
+        port: parseInt(form.port.value),
+        websocketPort: parseInt(form.websocketPort.value),
+        ssl: form.ssl.checked,
+        token: form.token.value,
+      };
+    },
+
+    onAddrChange(form) {
+      if (form.name.value.length && !form.address.value.startsWith(form.name.value)) {
+        return;
+      }
+
+      form.name.value = form.address.value;
+    },
+
+    onPortChange(form) {
+      const port = form.port.value;
+      if (!this.isPortValid(port)) return;
+      form.websocketPort.value = '' + (parseInt(port) + 1);
+    },
+
+    isPortValid(port) {
+      port = parseInt(port);
+      return !isNaN(port) && port > 0 && port < 65536;
+    },
+
+    isHostFormValid(form) {
+      return form.name.value.length && form.address.value.length && this.isPortValid(form.port.value) && this.isPortValid(form.websocketPort.value);
+    },
   },
 };