Browse Source

refactor: merge branch 'main' into refactor/monorepo

Andrew Bastin 4 years ago
parent
commit
98b9660956
98 changed files with 3383 additions and 2817 deletions
  1. 3 1
      .gitignore
  2. 8 5
      package.json
  3. 0 0
      packages/hoppscotch-app/.eslintrc.js
  4. 111 0
      packages/hoppscotch-app/.gitignore
  5. 1 0
      packages/hoppscotch-app/assets/icons/corner-down-left.svg
  6. 35 8
      packages/hoppscotch-app/assets/scss/styles.scss
  7. 280 173
      packages/hoppscotch-app/components/collections/SaveRequest.vue
  8. 1 1
      packages/hoppscotch-app/components/firebase/Login.vue
  9. 0 254
      packages/hoppscotch-app/components/graphql/QueryEditor.vue
  10. 18 31
      packages/hoppscotch-app/components/graphql/Request.vue
  11. 196 249
      packages/hoppscotch-app/components/graphql/RequestOptions.vue
  12. 61 93
      packages/hoppscotch-app/components/graphql/Response.vue
  13. 159 182
      packages/hoppscotch-app/components/graphql/Sidebar.vue
  14. 26 20
      packages/hoppscotch-app/components/http/CodegenModal.vue
  15. 68 98
      packages/hoppscotch-app/components/http/Headers.vue
  16. 103 103
      packages/hoppscotch-app/components/http/ImportCurl.vue
  17. 69 91
      packages/hoppscotch-app/components/http/Parameters.vue
  18. 43 31
      packages/hoppscotch-app/components/http/PreRequestScript.vue
  19. 81 69
      packages/hoppscotch-app/components/http/RawBody.vue
  20. 20 23
      packages/hoppscotch-app/components/http/ResponseMeta.vue
  21. 35 11
      packages/hoppscotch-app/components/http/Tests.vue
  22. 2 2
      packages/hoppscotch-app/components/lenses/HeadersRenderer.vue
  23. 119 90
      packages/hoppscotch-app/components/lenses/renderers/HTMLLensRenderer.vue
  24. 4 6
      packages/hoppscotch-app/components/lenses/renderers/ImageLensRenderer.vue
  25. 236 80
      packages/hoppscotch-app/components/lenses/renderers/JSONLensRenderer.vue
  26. 94 70
      packages/hoppscotch-app/components/lenses/renderers/RawLensRenderer.vue
  27. 95 70
      packages/hoppscotch-app/components/lenses/renderers/XMLLensRenderer.vue
  28. 9 23
      packages/hoppscotch-app/components/realtime/Log.vue
  29. 1 1
      packages/hoppscotch-app/components/realtime/Mqtt.vue
  30. 1 1
      packages/hoppscotch-app/components/realtime/Socketio.vue
  31. 1 1
      packages/hoppscotch-app/components/realtime/Sse.vue
  32. 1 1
      packages/hoppscotch-app/components/realtime/Websocket.vue
  33. 0 282
      packages/hoppscotch-app/components/smart/AceEditor.vue
  34. 10 0
      packages/hoppscotch-app/components/smart/AutoComplete.vue
  35. 1 2
      packages/hoppscotch-app/components/smart/EnvInput.vue
  36. 0 292
      packages/hoppscotch-app/components/smart/JsEditor.vue
  37. 1 1
      packages/hoppscotch-app/helpers/GQLConnection.ts
  38. 7 7
      packages/hoppscotch-app/helpers/__tests__/editorutils.spec.js
  39. 0 2
      packages/hoppscotch-app/helpers/codegen/codegen.ts
  40. 1 1
      packages/hoppscotch-app/helpers/codegen/generators/c-libcurl.js
  41. 1 1
      packages/hoppscotch-app/helpers/codegen/generators/cs-restsharp.js
  42. 1 1
      packages/hoppscotch-app/helpers/codegen/generators/curl.js
  43. 3 3
      packages/hoppscotch-app/helpers/codegen/generators/go-native.js
  44. 1 1
      packages/hoppscotch-app/helpers/codegen/generators/java-okhttp.js
  45. 1 1
      packages/hoppscotch-app/helpers/codegen/generators/java-unirest.js
  46. 1 1
      packages/hoppscotch-app/helpers/codegen/generators/javascript-fetch.js
  47. 1 1
      packages/hoppscotch-app/helpers/codegen/generators/javascript-jquery.js
  48. 1 1
      packages/hoppscotch-app/helpers/codegen/generators/javascript-xhr.js
  49. 1 1
      packages/hoppscotch-app/helpers/codegen/generators/nodejs-axios.js
  50. 1 1
      packages/hoppscotch-app/helpers/codegen/generators/nodejs-native.js
  51. 1 1
      packages/hoppscotch-app/helpers/codegen/generators/nodejs-request.js
  52. 1 1
      packages/hoppscotch-app/helpers/codegen/generators/nodejs-unirest.js
  53. 1 1
      packages/hoppscotch-app/helpers/codegen/generators/php-curl.js
  54. 1 1
      packages/hoppscotch-app/helpers/codegen/generators/powershell-restmethod.js
  55. 1 1
      packages/hoppscotch-app/helpers/codegen/generators/python-http-client.js
  56. 3 3
      packages/hoppscotch-app/helpers/codegen/generators/python-requests.js
  57. 1 1
      packages/hoppscotch-app/helpers/codegen/generators/ruby-net-http.js
  58. 1 1
      packages/hoppscotch-app/helpers/codegen/generators/salesforce-apex.js
  59. 1 1
      packages/hoppscotch-app/helpers/codegen/generators/shell-httpie.js
  60. 1 1
      packages/hoppscotch-app/helpers/codegen/generators/shell-wget.js
  61. 215 0
      packages/hoppscotch-app/helpers/editor/codemirror.ts
  62. 27 0
      packages/hoppscotch-app/helpers/editor/completion/gqlQuery.ts
  63. 23 0
      packages/hoppscotch-app/helpers/editor/completion/index.ts
  64. 24 0
      packages/hoppscotch-app/helpers/editor/completion/preRequest.ts
  65. 24 0
      packages/hoppscotch-app/helpers/editor/completion/testScript.ts
  66. 58 0
      packages/hoppscotch-app/helpers/editor/linting/gqlQuery.ts
  67. 21 0
      packages/hoppscotch-app/helpers/editor/linting/json.ts
  68. 7 0
      packages/hoppscotch-app/helpers/editor/linting/linter.ts
  69. 69 0
      packages/hoppscotch-app/helpers/editor/linting/preRequest.ts
  70. 69 0
      packages/hoppscotch-app/helpers/editor/linting/testScript.ts
  71. 80 0
      packages/hoppscotch-app/helpers/editor/modes/graphql.ts
  72. 38 0
      packages/hoppscotch-app/helpers/editor/utils.ts
  73. 7 7
      packages/hoppscotch-app/helpers/editorutils.js
  74. 102 23
      packages/hoppscotch-app/helpers/jsonParse.ts
  75. 4 2
      packages/hoppscotch-app/helpers/lenses/htmlLens.ts
  76. 4 2
      packages/hoppscotch-app/helpers/lenses/imageLens.ts
  77. 3 2
      packages/hoppscotch-app/helpers/lenses/jsonLens.ts
  78. 0 28
      packages/hoppscotch-app/helpers/lenses/lenses.js
  79. 42 0
      packages/hoppscotch-app/helpers/lenses/lenses.ts
  80. 0 8
      packages/hoppscotch-app/helpers/lenses/rawLens.js
  81. 11 0
      packages/hoppscotch-app/helpers/lenses/rawLens.ts
  82. 5 2
      packages/hoppscotch-app/helpers/lenses/xmlLens.ts
  83. 100 0
      packages/hoppscotch-app/helpers/newOutline.ts
  84. 0 124
      packages/hoppscotch-app/helpers/outline.js
  85. 8 0
      packages/hoppscotch-app/helpers/types/HoppRequestSaveContext.ts
  86. 3 3
      packages/hoppscotch-app/helpers/utils/StreamUtils.ts
  87. 1 1
      packages/hoppscotch-app/helpers/utils/b64.ts
  88. 0 12
      packages/hoppscotch-app/helpers/utils/string.js
  89. 12 0
      packages/hoppscotch-app/helpers/utils/string.ts
  90. 1 1
      packages/hoppscotch-app/layouts/default.vue
  91. 1 0
      packages/hoppscotch-app/locales/en.json
  92. 134 0
      packages/hoppscotch-app/modules/emit-volar-types.ts
  93. 2 1
      packages/hoppscotch-app/nuxt.config.js
  94. 17 19
      packages/hoppscotch-app/package.json
  95. 5 10
      packages/hoppscotch-app/pages/documentation.vue
  96. 25 31
      packages/hoppscotch-app/pages/graphql.vue
  97. 1 1
      packages/hoppscotch-app/pages/settings.vue
  98. 315 141
      pnpm-lock.yaml

+ 3 - 1
.gitignore

@@ -106,4 +106,6 @@ tests/*/screenshots
 tests/*/videos
 
 # Local Netlify folder
-.netlify
+.netlify
+# Andrew's crazy Volar shim generator
+shims-volar.d.ts

+ 8 - 5
package.json

@@ -6,12 +6,11 @@
   "private": true,
   "scripts": {
     "test": "echo \"Error: no test specified\" && exit 1",
+    "preinstall": "npx only-allow pnpm",
     "prepare": "husky install",
-    "pre-commit": "lint-staged"
-  },
-  "lint-staged": {
-    "*.{ts,js,vue}": "eslint",
-    "*.{css,scss,vue}": "stylelint"
+    "dev": "pnpm -r dev",
+    "lintfix": "pnpm -r lintfix",
+    "pre-commit": "pnpm -r lint"
   },
   "workspaces": [
     "./packages/*"
@@ -19,5 +18,9 @@
   "dependencies": {
     "husky": "^7.0.2",
     "lint-staged": "^11.1.2"
+  },
+  "devDependencies": {
+    "@commitlint/cli": "^13.1.0",
+    "@commitlint/config-conventional": "^13.1.0"
   }
 }

+ 0 - 0
.eslintrc.js → packages/hoppscotch-app/.eslintrc.js


+ 111 - 0
packages/hoppscotch-app/.gitignore

@@ -0,0 +1,111 @@
+# Created by .ignore support plugin (hsz.mobi)
+
+# Firebase
+.firebase
+
+### Node template
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# TypeScript v1 declaration files
+typings/
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variables file
+.env
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+
+# next.js build output
+.next
+
+# nuxt.js build output
+.nuxt
+
+# Nuxt generate
+dist
+
+# vuepress build output
+.vuepress/dist
+
+# Serverless directories
+.serverless
+
+# IDE / Editor
+.idea
+
+# Service worker
+sw.*
+
+# Mac OSX
+.DS_Store
+
+# Vim swap files
+*.swp
+
+# Build data
+.hoppscotch
+
+# File explorer
+.directory
+
+# Tests screenshots
+tests/*/screenshots
+
+# Tests videos
+tests/*/videos
+
+# Local Netlify folder
+.netlify
+# Andrew's crazy Volar shim generator
+shims-volar.d.ts

+ 1 - 0
packages/hoppscotch-app/assets/icons/corner-down-left.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 10 4 15 9 20"></polyline><path d="M20 4v7a4 4 0 0 1-4 4H4"></path></svg>

+ 35 - 8
packages/hoppscotch-app/assets/scss/styles.scss

@@ -17,7 +17,7 @@
 ::-webkit-scrollbar-thumb {
   @apply bg-divider bg-clip-content;
   @apply rounded-full;
-  @apply border-solid border-4 border-transparent;
+  @apply border-solid border-transparent border-4;
   @apply hover:(bg-dividerDark bg-clip-content);
 }
 
@@ -36,8 +36,9 @@
 }
 
 input::placeholder,
-textarea::placeholder {
-  @apply text-secondaryDark;
+textarea::placeholder,
+.CodeMirror-empty {
+  @apply text-secondary;
   @apply opacity-25;
 }
 
@@ -116,8 +117,8 @@ a {
 
   &.link {
     @apply items-center;
-    @apply px-1 py-0.5;
-    @apply -mx-1 -my-0.5;
+    @apply py-0.5 px-1;
+    @apply -my-0.5 -mx-1;
     @apply text-accent;
     @apply rounded;
     @apply hover:text-accentDark;
@@ -198,7 +199,7 @@ hr {
 .textarea {
   @apply flex;
   @apply w-full;
-  @apply px-4 py-2;
+  @apply py-2 px-4;
   @apply bg-transparent;
   @apply rounded;
   @apply text-secondaryDark;
@@ -293,7 +294,7 @@ input[type="checkbox"] {
     @apply cursor-pointer;
 
     &::before {
-      @apply border-2 border-divider;
+      @apply border-divider border-2;
       @apply rounded;
       @apply inline-flex;
       @apply items-center;
@@ -347,6 +348,7 @@ input[type="checkbox"] {
       @apply justify-start;
       @apply shadow;
       @apply font-medium;
+      @apply transition;
 
       font-size: var(--body-font-size);
       line-height: var(--body-line-height);
@@ -358,7 +360,6 @@ input[type="checkbox"] {
         @apply ml-auto;
         @apply last:ml-4;
         @apply sm:ml-8;
-        @apply transition;
         @apply rounded;
         @apply text-current;
         @apply normal-case;
@@ -461,6 +462,32 @@ input[type="checkbox"] {
   @apply w-full;
 }
 
+.CodeMirror {
+  @apply !h-auto;
+
+  font-size: var(--body-font-size);
+
+  &:not(.CodeMirror-focused) .CodeMirror-activeline-background {
+    background: transparent !important;
+  }
+
+  .CodeMirror-dialog-top {
+    @apply bg-primaryLight;
+    @apply border-dividerLight;
+    @apply px-4;
+    @apply py-2;
+    @apply z-5;
+  }
+
+  .CodeMirror-scroll {
+    @apply min-h-64;
+  }
+
+  * {
+    font-family: "Roboto Mono", monospace;
+  }
+}
+
 @media (max-width: 767px) {
   main {
     margin-bottom: env(safe-area-inset-bottom);

+ 280 - 173
packages/hoppscotch-app/components/collections/SaveRequest.vue

@@ -1,5 +1,9 @@
 <template>
-  <SmartModal v-if="show" :title="$t('collection.save_as')" @close="hideModal">
+  <SmartModal
+    v-if="show"
+    :title="$t('collection.save_as').toString()"
+    @close="hideModal"
+  >
     <template #body>
       <div class="flex flex-col px-2">
         <div class="flex relative">
@@ -41,11 +45,11 @@
     <template #footer>
       <span>
         <ButtonPrimary
-          :label="$t('action.save')"
+          :label="$t('action.save').toString()"
           @click.native="saveRequestAs"
         />
         <ButtonSecondary
-          :label="$t('action.cancel')"
+          :label="$t('action.cancel').toString()"
           @click.native="hideModal"
         />
       </span>
@@ -53,191 +57,294 @@
   </SmartModal>
 </template>
 
-<script>
-import { defineComponent } from "@nuxtjs/composition-api"
-import * as teamUtils from "~/helpers/teams/utils"
+<script setup lang="ts">
+import { reactive, ref, useContext, watch } from "@nuxtjs/composition-api"
+import { isHoppRESTRequest } from "~/helpers/types/HoppRESTRequest"
 import {
-  saveRESTRequestAs,
-  editRESTRequest,
   editGraphqlRequest,
+  editRESTRequest,
   saveGraphqlRequestAs,
+  saveRESTRequestAs,
 } from "~/newstore/collections"
 import { getGQLSession, useGQLRequestName } from "~/newstore/GQLSession"
 import {
   getRESTRequest,
-  useRESTRequestName,
   setRESTSaveContext,
+  useRESTRequestName,
 } from "~/newstore/RESTSession"
+import * as teamUtils from "~/helpers/teams/utils"
+import { apolloClient } from "~/helpers/apollo"
+import { HoppGQLRequest } from "~/helpers/types/HoppGQLRequest"
 
-export default defineComponent({
-  props: {
-    // mode can be either "graphql" or "rest"
-    mode: { type: String, default: "rest" },
-    show: Boolean,
-  },
-  setup(props) {
-    return {
-      requestName:
-        props.mode === "rest" ? useRESTRequestName() : useGQLRequestName(),
-    }
-  },
-  data() {
-    return {
-      requestData: {
-        name: this.requestName,
-        collectionIndex: undefined,
-        folderName: undefined,
-        requestIndex: undefined,
-      },
-      collectionsType: {
-        type: "my-collections",
-        selectedTeam: undefined,
-      },
-      picked: null,
+type CollectionType =
+  | {
+      type: "my-collections"
     }
-  },
-  watch: {
-    "requestData.collectionIndex": function resetFolderAndRequestIndex() {
-      // if user has chosen some folder, than selected other collection, which doesn't have any folders
-      // than `requestUpdateData.folderName` won't be reseted
-      this.$data.requestData.folderName = undefined
-      this.$data.requestData.requestIndex = undefined
-    },
-    "requestData.folderName": function resetRequestIndex() {
-      this.$data.requestData.requestIndex = undefined
-    },
-    editingRequest({ name }) {
-      this.$data.requestData.name = name || this.$data.defaultRequestName
-    },
-  },
-  methods: {
-    onUpdateCollType(newCollType) {
-      this.collectionsType = newCollType
-    },
-    onSelect({ picked }) {
-      this.picked = picked
-    },
-    async saveRequestAs() {
-      if (!this.requestName) {
-        this.$toast.error(this.$t("error.empty_req_name"), {
-          icon: "error_outline",
-        })
-        return
-      }
-      if (this.picked == null) {
-        this.$toast.error(this.$t("collection.select"), {
-          icon: "error_outline",
-        })
-        return
+  | {
+      type: "team-collections"
+      // TODO: Figure this type out
+      selectedTeam: {
+        id: string
       }
+    }
+
+type Picked =
+  | {
+      pickedType: "my-request"
+      folderPath: string
+      requestIndex: number
+    }
+  | {
+      pickedType: "my-folder"
+      folderPath: string
+    }
+  | {
+      pickedType: "my-collection"
+      collectionIndex: number
+    }
+  | {
+      pickedType: "teams-request"
+      requestID: string
+    }
+  | {
+      pickedType: "teams-folder"
+      folderID: string
+    }
+  | {
+      pickedType: "teams-collection"
+      collectionID: string
+    }
+  | {
+      pickedType: "gql-my-request"
+      folderPath: string
+      requestIndex: number
+    }
+  | {
+      pickedType: "gql-my-folder"
+      folderPath: string
+    }
+  | {
+      pickedType: "gql-my-collection"
+      collectionIndex: number
+    }
 
-      const requestUpdated =
-        this.mode === "rest" ? getRESTRequest() : getGQLSession()
+const props = defineProps<{
+  mode: "rest" | "graphql"
+  show: boolean
+}>()
 
-      // Filter out all REST file inputs
-      if (this.mode === "rest" && requestUpdated.bodyParams) {
-        requestUpdated.bodyParams = requestUpdated.bodyParams.map((param) =>
-          param?.value?.[0] instanceof File ? { ...param, value: "" } : param
-        )
-      }
+const emit = defineEmits<{
+  (e: "hide-modal"): void
+}>()
 
-      if (this.picked.pickedType === "my-request") {
-        editRESTRequest(
-          this.picked.folderPath,
-          this.picked.requestIndex,
-          requestUpdated
-        )
-        setRESTSaveContext({
-          originLocation: "user-collection",
-          folderPath: this.picked.folderPath,
-          requestIndex: this.picked.requestIndex,
-        })
-      } else if (this.picked.pickedType === "my-folder") {
-        const insertionIndex = saveRESTRequestAs(
-          this.picked.folderPath,
-          requestUpdated
-        )
-        setRESTSaveContext({
-          originLocation: "user-collection",
-          folderPath: this.picked.folderPath,
-          requestIndex: insertionIndex,
-        })
-      } else if (this.picked.pickedType === "my-collection") {
-        const insertionIndex = saveRESTRequestAs(
-          `${this.picked.collectionIndex}`,
-          requestUpdated
-        )
-        setRESTSaveContext({
-          originLocation: "user-collection",
-          folderPath: `${this.picked.collectionIndex}`,
-          requestIndex: insertionIndex,
-        })
-      } else if (this.picked.pickedType === "teams-request") {
-        teamUtils.overwriteRequestTeams(
-          this.$apollo,
-          JSON.stringify(requestUpdated),
-          requestUpdated.name,
-          this.picked.requestID
-        )
-        setRESTSaveContext({
-          originLocation: "team-collection",
-          requestID: this.picked.requestID,
-        })
-      } else if (this.picked.pickedType === "teams-folder") {
-        const req = await teamUtils.saveRequestAsTeams(
-          this.$apollo,
-          JSON.stringify(requestUpdated),
-          requestUpdated.name,
-          this.collectionsType.selectedTeam.id,
-          this.picked.folderID
-        )
-
-        if (req && req.id) {
-          setRESTSaveContext({
-            originLocation: "team-collection",
-            requestID: req.id,
-            teamID: this.collectionsType.selectedTeam.id,
-            collectionID: this.picked.folderID,
-          })
-        }
-      } else if (this.picked.pickedType === "teams-collection") {
-        const req = await teamUtils.saveRequestAsTeams(
-          this.$apollo,
-          JSON.stringify(requestUpdated),
-          requestUpdated.name,
-          this.collectionsType.selectedTeam.id,
-          this.picked.collectionID
-        )
-
-        if (req && req.id) {
-          setRESTSaveContext({
-            originLocation: "team-collection",
-            requestID: req.id,
-            teamID: this.collectionsType.selectedTeam.id,
-            collectionID: this.picked.collectionID,
-          })
-        }
-      } else if (this.picked.pickedType === "gql-my-request") {
-        editGraphqlRequest(
-          this.picked.folderPath,
-          this.picked.requestIndex,
-          requestUpdated
-        )
-      } else if (this.picked.pickedType === "gql-my-folder") {
-        saveGraphqlRequestAs(this.picked.folderPath, requestUpdated)
-      } else if (this.picked.pickedType === "gql-my-collection") {
-        saveGraphqlRequestAs(`${this.picked.collectionIndex}`, requestUpdated)
-      }
-      this.$toast.success(this.$t("request.added"), {
-        icon: "post_add",
-      })
+const {
+  $toast,
+  app: { i18n },
+} = useContext()
+
+const t = i18n.t.bind(i18n)
+
+// TODO: Use a better implementation with computed ?
+// This implementation can't work across updates to mode prop (which won't happen tho)
+const requestName =
+  props.mode === "rest" ? useRESTRequestName() : useGQLRequestName()
+
+const requestData = reactive({
+  name: requestName,
+  collectionIndex: undefined as number | undefined,
+  folderName: undefined as number | undefined,
+  requestIndex: undefined as number | undefined,
+})
 
-      this.hideModal()
-    },
-    hideModal() {
-      this.picked = null
-      this.$emit("hide-modal")
-    },
-  },
+const collectionsType = ref<CollectionType>({
+  type: "my-collections",
 })
+
+// TODO: Figure this type out
+const picked = ref<Picked | null>(null)
+
+// Resets
+watch(
+  () => requestData.collectionIndex,
+  () => {
+    requestData.folderName = undefined
+    requestData.requestIndex = undefined
+  }
+)
+watch(
+  () => requestData.folderName,
+  () => {
+    requestData.requestIndex = undefined
+  }
+)
+
+// All the methods
+const onUpdateCollType = (newCollType: CollectionType) => {
+  collectionsType.value = newCollType
+}
+
+const onSelect = ({ picked: pickedVal }: { picked: Picked | null }) => {
+  picked.value = pickedVal
+}
+
+const hideModal = () => {
+  picked.value = null
+  emit("hide-modal")
+}
+
+const saveRequestAs = async () => {
+  if (!requestName.value) {
+    $toast.error(t("error.empty_req_name").toString(), {
+      icon: "error_outline",
+    })
+    return
+  }
+  if (picked.value === null) {
+    $toast.error(t("collection.select").toString(), {
+      icon: "error_outline",
+    })
+    return
+  }
+
+  const requestUpdated =
+    props.mode === "rest" ? getRESTRequest() : getGQLSession().request
+
+  // // Filter out all REST file inputs
+  // if (this.mode === "rest" && requestUpdated.bodyParams) {
+  //   requestUpdated.bodyParams = requestUpdated.bodyParams.map((param) =>
+  //     param?.value?.[0] instanceof File ? { ...param, value: "" } : param
+  //   )
+  // }
+
+  if (picked.value.pickedType === "my-request") {
+    if (!isHoppRESTRequest(requestUpdated))
+      throw new Error("requestUpdated is not a REST Request")
+
+    editRESTRequest(
+      picked.value.folderPath,
+      picked.value.requestIndex,
+      requestUpdated
+    )
+
+    setRESTSaveContext({
+      originLocation: "user-collection",
+      folderPath: picked.value.folderPath,
+      requestIndex: picked.value.requestIndex,
+    })
+  } else if (picked.value.pickedType === "my-folder") {
+    if (!isHoppRESTRequest(requestUpdated))
+      throw new Error("requestUpdated is not a REST Request")
+
+    const insertionIndex = saveRESTRequestAs(
+      picked.value.folderPath,
+      requestUpdated
+    )
+
+    setRESTSaveContext({
+      originLocation: "user-collection",
+      folderPath: picked.value.folderPath,
+      requestIndex: insertionIndex,
+    })
+  } else if (picked.value.pickedType === "my-collection") {
+    if (!isHoppRESTRequest(requestUpdated))
+      throw new Error("requestUpdated is not a REST Request")
+
+    const insertionIndex = saveRESTRequestAs(
+      `${picked.value.collectionIndex}`,
+      requestUpdated
+    )
+    setRESTSaveContext({
+      originLocation: "user-collection",
+      folderPath: `${picked.value.collectionIndex}`,
+      requestIndex: insertionIndex,
+    })
+  } else if (picked.value.pickedType === "teams-request") {
+    if (isHoppRESTRequest(requestUpdated))
+      throw new Error("requestUpdated is not a REST Request")
+
+    if (collectionsType.value.type !== "team-collections")
+      throw new Error("Collections Type mismatch")
+
+    teamUtils.overwriteRequestTeams(
+      apolloClient,
+      JSON.stringify(requestUpdated),
+      requestUpdated.name,
+      picked.value.requestID
+    )
+
+    setRESTSaveContext({
+      originLocation: "team-collection",
+      requestID: picked.value.requestID,
+    })
+  } else if (picked.value.pickedType === "teams-folder") {
+    if (isHoppRESTRequest(requestUpdated))
+      throw new Error("requestUpdated is not a REST Request")
+
+    if (collectionsType.value.type !== "team-collections")
+      throw new Error("Collections Type mismatch")
+
+    const req = await teamUtils.saveRequestAsTeams(
+      apolloClient,
+      JSON.stringify(requestUpdated),
+      requestUpdated.name,
+      collectionsType.value.selectedTeam.id,
+      picked.value.folderID
+    )
+
+    if (req && req.id) {
+      setRESTSaveContext({
+        originLocation: "team-collection",
+        requestID: req.id,
+        teamID: collectionsType.value.selectedTeam.id,
+        collectionID: picked.value.folderID,
+      })
+    }
+  } else if (picked.value.pickedType === "teams-collection") {
+    if (isHoppRESTRequest(requestUpdated))
+      throw new Error("requestUpdated is not a REST Request")
+
+    if (collectionsType.value.type !== "team-collections")
+      throw new Error("Collections Type mismatch")
+
+    const req = await teamUtils.saveRequestAsTeams(
+      apolloClient,
+      JSON.stringify(requestUpdated),
+      requestUpdated.name,
+      collectionsType.value.selectedTeam.id,
+      picked.value.collectionID
+    )
+
+    if (req && req.id) {
+      setRESTSaveContext({
+        originLocation: "team-collection",
+        requestID: req.id,
+        teamID: collectionsType.value.selectedTeam.id,
+        collectionID: picked.value.collectionID,
+      })
+    }
+  } else if (picked.value.pickedType === "gql-my-request") {
+    // TODO: Check for GQL request ?
+    editGraphqlRequest(
+      picked.value.folderPath,
+      picked.value.requestIndex,
+      requestUpdated as HoppGQLRequest
+    )
+  } else if (picked.value.pickedType === "gql-my-folder") {
+    // TODO: Check for GQL request ?
+    saveGraphqlRequestAs(
+      picked.value.folderPath,
+      requestUpdated as HoppGQLRequest
+    )
+  } else if (picked.value.pickedType === "gql-my-collection") {
+    // TODO: Check for GQL request ?
+    saveGraphqlRequestAs(
+      `${picked.value.collectionIndex}`,
+      requestUpdated as HoppGQLRequest
+    )
+  }
+  $toast.success(t("request.added").toString(), {
+    icon: "post_add",
+  })
+  hideModal()
+}
 </script>

+ 1 - 1
packages/hoppscotch-app/components/firebase/Login.vue

@@ -26,7 +26,7 @@
         />
       </div>
       <div v-if="mode === 'email'" class="flex flex-col space-y-2">
-        <div class="flex items-center relative">
+        <div class="flex flex-col">
           <input
             id="email"
             v-model="form.email"

+ 0 - 254
packages/hoppscotch-app/components/graphql/QueryEditor.vue

@@ -1,254 +0,0 @@
-<template>
-  <div class="opacity-0 show-if-initialized" :class="{ initialized }">
-    <pre ref="editor" :class="styles"></pre>
-  </div>
-</template>
-
-<script>
-import ace from "ace-builds"
-import "ace-builds/webpack-resolver"
-import "ace-builds/src-noconflict/ext-language_tools"
-import "ace-builds/src-noconflict/mode-graphqlschema"
-import * as gql from "graphql"
-import { getAutocompleteSuggestions } from "graphql-language-service-interface"
-import { defineComponent } from "@nuxtjs/composition-api"
-import { defineGQLLanguageMode } from "~/helpers/syntax/gqlQueryLangMode"
-import debounce from "~/helpers/utils/debounce"
-
-export default defineComponent({
-  props: {
-    value: {
-      type: String,
-      default: "",
-    },
-    theme: {
-      type: String,
-      required: false,
-      default: null,
-    },
-    onRunGQLQuery: {
-      type: Function,
-      default: () => {},
-    },
-    options: {
-      type: Object,
-      default: () => {},
-    },
-    styles: {
-      type: String,
-      default: "",
-    },
-  },
-
-  data() {
-    return {
-      initialized: false,
-      editor: null,
-      cacheValue: "",
-      validationSchema: null,
-    }
-  },
-
-  computed: {
-    appFontSize() {
-      return getComputedStyle(document.documentElement).getPropertyValue(
-        "--body-font-size"
-      )
-    },
-  },
-
-  watch: {
-    value(value) {
-      if (value !== this.cacheValue) {
-        this.editor.session.setValue(value, 1)
-        this.cacheValue = value
-      }
-    },
-    theme() {
-      this.initialized = false
-      this.editor.setTheme(`ace/theme/${this.defineTheme()}`, () => {
-        this.$nextTick().then(() => {
-          this.initialized = true
-        })
-      })
-    },
-    options(value) {
-      this.editor.setOptions(value)
-    },
-  },
-
-  mounted() {
-    defineGQLLanguageMode(ace)
-
-    const langTools = ace.require("ace/ext/language_tools")
-
-    const editor = ace.edit(this.$refs.editor, {
-      mode: `ace/mode/gql-query`,
-      enableBasicAutocompletion: true,
-      enableLiveAutocompletion: true,
-      ...this.options,
-    })
-
-    // Set the theme and show the editor only after it's been set to prevent FOUC.
-    editor.setTheme(`ace/theme/${this.defineTheme()}`, () => {
-      this.$nextTick().then(() => {
-        this.initialized = true
-      })
-    })
-
-    // Set the theme and show the editor only after it's been set to prevent FOUC.
-    editor.setTheme(`ace/theme/${this.defineTheme()}`, () => {
-      this.$nextTick().then(() => {
-        this.initialized = true
-      })
-    })
-
-    editor.setFontSize(this.appFontSize)
-
-    const completer = {
-      getCompletions: (
-        editor,
-        _session,
-        { row, column },
-        _prefix,
-        callback
-      ) => {
-        if (this.validationSchema) {
-          const completions = getAutocompleteSuggestions(
-            this.validationSchema,
-            editor.getValue(),
-            {
-              line: row,
-              character: column,
-            }
-          )
-
-          callback(
-            null,
-            completions.map(({ label, detail }) => ({
-              name: label,
-              value: label,
-              score: 1.0,
-              meta: detail,
-            }))
-          )
-        } else {
-          callback(null, [])
-        }
-      },
-    }
-
-    langTools.setCompleters([completer])
-
-    if (this.value) editor.setValue(this.value, 1)
-
-    this.editor = editor
-    this.cacheValue = this.value
-
-    editor.commands.addCommand({
-      name: "runGQLQuery",
-      exec: () => this.onRunGQLQuery(this.editor.getValue()),
-      bindKey: {
-        mac: "cmd-enter",
-        win: "ctrl-enter",
-      },
-    })
-
-    editor.commands.addCommand({
-      name: "prettifyGQLQuery",
-      exec: () => this.prettifyQuery(),
-      bindKey: {
-        mac: "cmd-p",
-        win: "ctrl-p",
-      },
-    })
-
-    editor.on("change", () => {
-      const content = editor.getValue()
-      this.$emit("input", content)
-      this.parseContents(content)
-      this.cacheValue = content
-    })
-
-    this.parseContents(this.value)
-  },
-
-  beforeDestroy() {
-    this.editor.destroy()
-  },
-
-  methods: {
-    prettifyQuery() {
-      try {
-        this.$emit("update-query", gql.print(gql.parse(this.editor.getValue())))
-      } catch (e) {
-        this.$toast.error(this.$t("error.gql_prettify_invalid_query"), {
-          icon: "error_outline",
-        })
-      }
-    },
-
-    defineTheme() {
-      if (this.theme) {
-        return this.theme
-      }
-      const strip = (str) =>
-        str.replace(/#/g, "").replace(/ /g, "").replace(/"/g, "")
-      return strip(
-        window
-          .getComputedStyle(document.documentElement)
-          .getPropertyValue("--editor-theme")
-      )
-    },
-
-    setValidationSchema(schema) {
-      this.validationSchema = schema
-      this.parseContents(this.cacheValue)
-    },
-
-    parseContents: debounce(function (content) {
-      if (content !== "") {
-        try {
-          const doc = gql.parse(content)
-
-          if (this.validationSchema) {
-            this.editor.session.setAnnotations(
-              gql
-                .validate(this.validationSchema, doc)
-                .map(({ locations, message }) => ({
-                  row: locations[0].line - 1,
-                  column: locations[0].column - 1,
-                  text: message,
-                  type: "error",
-                }))
-            )
-          }
-        } catch (e) {
-          this.editor.session.setAnnotations([
-            {
-              row: e.locations[0].line - 1,
-              column: e.locations[0].column - 1,
-              text: e.message,
-              type: "error",
-            },
-          ])
-        }
-      } else {
-        this.editor.session.setAnnotations([])
-      }
-    }, 2000),
-  },
-})
-</script>
-
-<style scoped lang="scss">
-.show-if-initialized {
-  &.initialized {
-    @apply opacity-100;
-  }
-
-  & > * {
-    @apply transition-none;
-  }
-}
-</style>

+ 18 - 31
packages/hoppscotch-app/components/graphql/Request.vue

@@ -33,45 +33,32 @@
   </div>
 </template>
 
-<script lang="ts">
-import { defineComponent, PropType } from "@nuxtjs/composition-api"
+<script setup lang="ts">
 import { logHoppRequestRunToAnalytics } from "~/helpers/fb/analytics"
 import { GQLConnection } from "~/helpers/GQLConnection"
 import { getCurrentStrategyID } from "~/helpers/network"
 import { useReadonlyStream, useStream } from "~/helpers/utils/composables"
 import { gqlHeaders$, gqlURL$, setGQLURL } from "~/newstore/GQLSession"
 
-export default defineComponent({
-  props: {
-    conn: {
-      type: Object as PropType<GQLConnection>,
-      required: true,
-    },
-  },
-  setup(props) {
-    const connected = useReadonlyStream(props.conn.connected$, false)
-    const headers = useReadonlyStream(gqlHeaders$, [])
+const props = defineProps<{
+  conn: GQLConnection
+}>()
 
-    const url = useStream(gqlURL$, "", setGQLURL)
+const connected = useReadonlyStream(props.conn.connected$, false)
+const headers = useReadonlyStream(gqlHeaders$, [])
 
-    const onConnectClick = () => {
-      if (!connected.value) {
-        props.conn.connect(url.value, headers.value as any)
+const url = useStream(gqlURL$, "", setGQLURL)
 
-        logHoppRequestRunToAnalytics({
-          platform: "graphql-schema",
-          strategy: getCurrentStrategyID(),
-        })
-      } else {
-        props.conn.disconnect()
-      }
-    }
+const onConnectClick = () => {
+  if (!connected.value) {
+    props.conn.connect(url.value, headers.value as any)
 
-    return {
-      url,
-      connected,
-      onConnectClick,
-    }
-  },
-})
+    logHoppRequestRunToAnalytics({
+      platform: "graphql-schema",
+      strategy: getCurrentStrategyID(),
+    })
+  } else {
+    props.conn.disconnect()
+  }
+}
 </script>

+ 196 - 249
packages/hoppscotch-app/components/graphql/RequestOptions.vue

@@ -42,9 +42,7 @@
               />
               <ButtonSecondary
                 v-tippy="{ theme: 'tooltip' }"
-                :title="`${$t(
-                  'action.prettify'
-                )} <kbd>${getSpecialKey()}</kbd><kbd>P</kbd>`"
+                :title="$t('action.prettify')"
                 :svg="prettifyQueryIcon"
                 @click.native="prettifyQuery"
               />
@@ -57,20 +55,7 @@
               />
             </div>
           </div>
-          <GraphqlQueryEditor
-            ref="queryEditor"
-            v-model="gqlQueryString"
-            :on-run-g-q-l-query="runQuery"
-            :options="{
-              maxLines: Infinity,
-              minLines: 16,
-              autoScrollEditorIntoView: true,
-              showPrintMargin: false,
-              useWorker: false,
-            }"
-            styles="border-b border-dividerLight"
-            @update-query="updateQuery"
-          />
+          <div ref="queryEditor"></div>
         </AppSection>
       </SmartTab>
 
@@ -108,19 +93,7 @@
               />
             </div>
           </div>
-          <SmartAceEditor
-            ref="variableEditor"
-            v-model="variableString"
-            :lang="'json'"
-            :options="{
-              maxLines: Infinity,
-              minLines: 16,
-              autoScrollEditorIntoView: true,
-              showPrintMargin: false,
-              useWorker: false,
-            }"
-            styles="border-b border-dividerLight"
-          />
+          <div ref="variableEditor"></div>
         </AppSection>
       </SmartTab>
 
@@ -173,27 +146,7 @@
               />
             </div>
           </div>
-          <div v-if="bulkMode" class="flex">
-            <textarea-autosize
-              v-model="bulkHeaders"
-              v-focus
-              name="bulk-parameters"
-              class="
-                bg-transparent
-                border-b border-dividerLight
-                flex
-                font-mono
-                flex-1
-                py-2
-                px-4
-                whitespace-pre
-                resize-y
-                overflow-auto
-              "
-              rows="10"
-              :placeholder="$t('state.bulk_mode_placeholder')"
-            />
-          </div>
+          <div v-if="bulkMode" ref="bulkEditor"></div>
           <div v-else>
             <div
               v-for="(header, index) in headers"
@@ -229,7 +182,9 @@
               />
               <input
                 class="bg-transparent flex flex-1 py-2 px-4"
-                :placeholder="$t('count.value', { count: index + 1 })"
+                :placeholder="
+                  $t('count.value', { count: index + 1 }).toString()
+                "
                 :name="`value ${index}`"
                 :value="header.value"
                 autofocus
@@ -311,17 +266,10 @@
   </div>
 </template>
 
-<script lang="ts">
-import {
-  defineComponent,
-  onMounted,
-  PropType,
-  ref,
-  useContext,
-  watch,
-} from "@nuxtjs/composition-api"
+<script setup lang="ts">
+import { onMounted, ref, useContext, watch } from "@nuxtjs/composition-api"
 import clone from "lodash/clone"
-import { getPlatformSpecialKey } from "~/helpers/platformutils"
+import * as gql from "graphql"
 import { copyToClipboard } from "~/helpers/utils/clipboard"
 import {
   useNuxt,
@@ -348,208 +296,207 @@ import { makeGQLHistoryEntry, addGraphqlHistoryEntry } from "~/newstore/history"
 import { logHoppRequestRunToAnalytics } from "~/helpers/fb/analytics"
 import { getCurrentStrategyID } from "~/helpers/network"
 import { makeGQLRequest } from "~/helpers/types/HoppGQLRequest"
+import { useCodemirror } from "~/helpers/editor/codemirror"
+import "codemirror/mode/javascript/javascript"
+import "~/helpers/editor/modes/graphql"
+import jsonLinter from "~/helpers/editor/linting/json"
+import { createGQLQueryLinter } from "~/helpers/editor/linting/gqlQuery"
+import queryCompleter from "~/helpers/editor/completion/gqlQuery"
+
+const props = defineProps<{
+  conn: GQLConnection
+}>()
+
+const {
+  $toast,
+  app: { i18n },
+} = useContext()
+const t = i18n.t.bind(i18n)
+const nuxt = useNuxt()
+
+const bulkMode = ref(false)
+const bulkHeaders = ref("")
+
+watch(bulkHeaders, () => {
+  try {
+    const transformation = bulkHeaders.value.split("\n").map((item) => ({
+      key: item.substring(0, item.indexOf(":")).trim().replace(/^\/\//, ""),
+      value: item.substring(item.indexOf(":") + 1).trim(),
+      active: !item.trim().startsWith("//"),
+    }))
+    setGQLHeaders(transformation)
+  } catch (e) {
+    $toast.error(t("error.something_went_wrong").toString(), {
+      icon: "error_outline",
+    })
+    console.error(e)
+  }
+})
 
-export default defineComponent({
-  props: {
-    conn: {
-      type: Object as PropType<GQLConnection>,
-      required: true,
-    },
+const url = useReadonlyStream(gqlURL$, "")
+const gqlQueryString = useStream(gqlQuery$, "", setGQLQuery)
+const variableString = useStream(gqlVariables$, "", setGQLVariables)
+const headers = useStream(gqlHeaders$, [], setGQLHeaders)
+
+const bulkEditor = ref<any | null>(null)
+
+useCodemirror(bulkEditor, bulkHeaders, {
+  extendedEditorConfig: {
+    mode: "text/x-yaml",
+    placeholder: t("state.bulk_mode_placeholder").toString(),
   },
-  setup(props) {
-    const {
-      $toast,
-      app: { i18n },
-    } = useContext()
-    const t = i18n.t.bind(i18n)
-    const nuxt = useNuxt()
-
-    const bulkMode = ref(false)
-    const bulkHeaders = ref("")
-
-    watch(bulkHeaders, () => {
-      try {
-        const transformation = bulkHeaders.value.split("\n").map((item) => ({
-          key: item.substring(0, item.indexOf(":")).trim().replace(/^\/\//, ""),
-          value: item.substring(item.indexOf(":") + 1).trim(),
-          active: !item.trim().startsWith("//"),
-        }))
-        setGQLHeaders(transformation)
-      } catch (e) {
-        $toast.error(t("error.something_went_wrong").toString(), {
-          icon: "error_outline",
-        })
-        console.error(e)
-      }
-    })
+  linter: null,
+  completer: null,
+})
 
-    const url = useReadonlyStream(gqlURL$, "")
-    const gqlQueryString = useStream(gqlQuery$, "", setGQLQuery)
-    const variableString = useStream(gqlVariables$, "", setGQLVariables)
-    const headers = useStream(gqlHeaders$, [], setGQLHeaders)
+const variableEditor = ref<any | null>(null)
 
-    const queryEditor = ref<any | null>(null)
+useCodemirror(variableEditor, variableString, {
+  extendedEditorConfig: {
+    mode: "application/ld+json",
+    placeholder: t("request.variables").toString(),
+  },
+  linter: jsonLinter,
+  completer: null,
+})
 
-    const copyQueryIcon = ref("copy")
-    const prettifyQueryIcon = ref("align-left")
-    const copyVariablesIcon = ref("copy")
+const queryEditor = ref<any | null>(null)
+const schemaString = useReadonlyStream(props.conn.schema$, null)
+
+useCodemirror(queryEditor, gqlQueryString, {
+  extendedEditorConfig: {
+    mode: "graphql",
+    placeholder: t("request.query").toString(),
+  },
+  linter: createGQLQueryLinter(schemaString),
+  completer: queryCompleter(schemaString),
+})
 
-    const showSaveRequestModal = ref(false)
+const copyQueryIcon = ref("copy")
+const prettifyQueryIcon = ref("align-left")
+const copyVariablesIcon = ref("copy")
 
-    const schema = useReadonlyStream(props.conn.schemaString$, "")
+const showSaveRequestModal = ref(false)
 
-    watch(
-      headers,
-      () => {
-        if (
-          (headers.value[headers.value.length - 1]?.key !== "" ||
-            headers.value[headers.value.length - 1]?.value !== "") &&
-          headers.value.length
-        )
-          addRequestHeader()
-      },
-      { deep: true }
+watch(
+  headers,
+  () => {
+    if (
+      (headers.value[headers.value.length - 1]?.key !== "" ||
+        headers.value[headers.value.length - 1]?.value !== "") &&
+      headers.value.length
     )
+      addRequestHeader()
+  },
+  { deep: true }
+)
 
-    onMounted(() => {
-      if (!headers.value?.length) {
-        addRequestHeader()
-      }
-    })
-
-    const copyQuery = () => {
-      copyToClipboard(gqlQueryString.value)
-      copyQueryIcon.value = "check"
-      setTimeout(() => (copyQueryIcon.value = "copy"), 1000)
-    }
-
-    const response = useStream(gqlResponse$, "", setGQLResponse)
-
-    const runQuery = async () => {
-      const startTime = Date.now()
-
-      nuxt.value.$loading.start()
-      response.value = t("state.loading").toString()
-
-      try {
-        const runURL = clone(url.value)
-        const runHeaders = clone(headers.value)
-        const runQuery = clone(gqlQueryString.value)
-        const runVariables = clone(variableString.value)
-
-        const responseText = await props.conn.runQuery(
-          runURL,
-          runHeaders,
-          runQuery,
-          runVariables
-        )
-        const duration = Date.now() - startTime
-
-        nuxt.value.$loading.finish()
-
-        response.value = JSON.stringify(JSON.parse(responseText), null, 2)
-
-        addGraphqlHistoryEntry(
-          makeGQLHistoryEntry({
-            request: makeGQLRequest({
-              name: "",
-              url: runURL,
-              query: runQuery,
-              headers: runHeaders,
-              variables: runVariables,
-            }),
-            response: response.value,
-            star: false,
-          })
-        )
-
-        $toast.success(t("state.finished_in", { duration }).toString(), {
-          icon: "done",
-        })
-      } catch (e: any) {
-        response.value = `${e}. ${t("error.check_console_details")}`
-        nuxt.value.$loading.finish()
-
-        $toast.error(`${e} ${t("error.f12_details").toString()}`, {
-          icon: "error_outline",
-        })
-        console.error(e)
-      }
-
-      logHoppRequestRunToAnalytics({
-        platform: "graphql-query",
-        strategy: getCurrentStrategyID(),
-      })
-    }
-
-    const hideRequestModal = () => {
-      showSaveRequestModal.value = false
-    }
-
-    const prettifyQuery = () => {
-      queryEditor.value.prettifyQuery()
-      prettifyQueryIcon.value = "check"
-      setTimeout(() => (prettifyQueryIcon.value = "align-left"), 1000)
-    }
-
-    const saveRequest = () => {
-      showSaveRequestModal.value = true
-    }
-
-    // Why ?
-    const updateQuery = (updatedQuery: string) => {
-      gqlQueryString.value = updatedQuery
-    }
-
-    const copyVariables = () => {
-      copyToClipboard(variableString.value)
-      copyVariablesIcon.value = "check"
-      setTimeout(() => (copyVariablesIcon.value = "copy"), 1000)
-    }
-
-    const addRequestHeader = () => {
-      addGQLHeader({
-        key: "",
-        value: "",
-        active: true,
-      })
-    }
+onMounted(() => {
+  if (!headers.value?.length) {
+    addRequestHeader()
+  }
+})
 
-    const removeRequestHeader = (index: number) => {
-      removeGQLHeader(index)
-    }
+const copyQuery = () => {
+  copyToClipboard(gqlQueryString.value)
+  copyQueryIcon.value = "check"
+  setTimeout(() => (copyQueryIcon.value = "copy"), 1000)
+}
 
-    return {
-      gqlQueryString,
-      variableString,
-      headers,
-      copyQueryIcon,
-      prettifyQueryIcon,
-      copyVariablesIcon,
+const response = useStream(gqlResponse$, "", setGQLResponse)
 
-      queryEditor,
+const runQuery = async () => {
+  const startTime = Date.now()
 
-      showSaveRequestModal,
-      hideRequestModal,
+  nuxt.value.$loading.start()
+  response.value = t("state.loading").toString()
 
-      schema,
+  try {
+    const runURL = clone(url.value)
+    const runHeaders = clone(headers.value)
+    const runQuery = clone(gqlQueryString.value)
+    const runVariables = clone(variableString.value)
 
-      copyQuery,
+    const responseText = await props.conn.runQuery(
+      runURL,
+      runHeaders,
       runQuery,
-      prettifyQuery,
-      saveRequest,
-      updateQuery,
-      copyVariables,
-      addRequestHeader,
-      removeRequestHeader,
-
-      getSpecialKey: getPlatformSpecialKey,
-
-      commonHeaders,
-      updateGQLHeader,
-      bulkMode,
-      bulkHeaders,
-    }
-  },
-})
+      runVariables
+    )
+    const duration = Date.now() - startTime
+
+    nuxt.value.$loading.finish()
+
+    response.value = JSON.stringify(JSON.parse(responseText), null, 2)
+
+    addGraphqlHistoryEntry(
+      makeGQLHistoryEntry({
+        request: makeGQLRequest({
+          name: "",
+          url: runURL,
+          query: runQuery,
+          headers: runHeaders,
+          variables: runVariables,
+        }),
+        response: response.value,
+        star: false,
+      })
+    )
+
+    $toast.success(t("state.finished_in", { duration }).toString(), {
+      icon: "done",
+    })
+  } catch (e: any) {
+    response.value = `${e}. ${t("error.check_console_details")}`
+    nuxt.value.$loading.finish()
+
+    $toast.error(`${e} ${t("error.f12_details").toString()}`, {
+      icon: "error_outline",
+    })
+    console.error(e)
+  }
+
+  logHoppRequestRunToAnalytics({
+    platform: "graphql-query",
+    strategy: getCurrentStrategyID(),
+  })
+}
+
+const hideRequestModal = () => {
+  showSaveRequestModal.value = false
+}
+
+const prettifyQuery = () => {
+  try {
+    gqlQueryString.value = gql.print(gql.parse(gqlQueryString.value))
+  } catch (e) {
+    $toast.error(t("error.gql_prettify_invalid_query").toString(), {
+      icon: "error_outline",
+    })
+  }
+  prettifyQueryIcon.value = "check"
+  setTimeout(() => (prettifyQueryIcon.value = "align-left"), 1000)
+}
+
+const saveRequest = () => {
+  showSaveRequestModal.value = true
+}
+
+const copyVariables = () => {
+  copyToClipboard(variableString.value)
+  copyVariablesIcon.value = "check"
+  setTimeout(() => (copyVariablesIcon.value = "copy"), 1000)
+}
+
+const addRequestHeader = () => {
+  addGQLHeader({
+    key: "",
+    value: "",
+    active: true,
+  })
+}
+
+const removeRequestHeader = (index: number) => {
+  removeGQLHeader(index)
+}
 </script>

+ 61 - 93
packages/hoppscotch-app/components/graphql/Response.vue

@@ -18,6 +18,13 @@
         {{ $t("response.title") }}
       </label>
       <div class="flex">
+        <ButtonSecondary
+          v-tippy="{ theme: 'tooltip' }"
+          :title="$t('state.linewrap')"
+          :class="{ '!text-accent': linewrapEnabled }"
+          svg="corner-down-left"
+          @click.native.prevent="linewrapEnabled = !linewrapEnabled"
+        />
         <ButtonSecondary
           ref="downloadResponse"
           v-tippy="{ theme: 'tooltip' }"
@@ -34,21 +41,7 @@
         />
       </div>
     </div>
-    <SmartAceEditor
-      v-if="responseString"
-      :value="responseString"
-      :lang="'json'"
-      :lint="false"
-      :options="{
-        maxLines: Infinity,
-        minLines: 16,
-        autoScrollEditorIntoView: true,
-        readOnly: true,
-        showPrintMargin: false,
-        useWorker: false,
-      }"
-      styles="border-b border-dividerLight"
-    />
+    <div v-if="responseString" ref="schemaEditor"></div>
     <div
       v-else
       class="
@@ -60,35 +53,21 @@
       "
     >
       <div class="flex space-x-2 pb-4">
-        <div class="flex flex-col space-y-4 items-end">
+        <div class="flex flex-col space-y-4 text-right items-end">
           <span class="flex flex-1 items-center">
-            {{ $t("shortcut.request.send_request") }}
-          </span>
-          <span class="flex flex-1 items-center">
-            {{ $t("shortcut.general.show_all") }}
-          </span>
-          <!-- <span class="flex flex-1 items-center">
             {{ $t("shortcut.general.command_menu") }}
           </span>
           <span class="flex flex-1 items-center">
             {{ $t("shortcut.general.help_menu") }}
-          </span> -->
+          </span>
         </div>
         <div class="flex flex-col space-y-4">
           <div class="flex">
-            <span class="shortcut-key">{{ getSpecialKey() }}</span>
-            <span class="shortcut-key">G</span>
-          </div>
-          <div class="flex">
-            <span class="shortcut-key">{{ getSpecialKey() }}</span>
-            <span class="shortcut-key">K</span>
-          </div>
-          <!-- <div class="flex">
             <span class="shortcut-key">/</span>
           </div>
           <div class="flex">
             <span class="shortcut-key">?</span>
-          </div> -->
+          </div>
         </div>
       </div>
       <ButtonSecondary
@@ -103,77 +82,66 @@
   </AppSection>
 </template>
 
-<script lang="ts">
-import {
-  defineComponent,
-  PropType,
-  ref,
-  useContext,
-} from "@nuxtjs/composition-api"
-import { GQLConnection } from "~/helpers/GQLConnection"
-import { getPlatformSpecialKey } from "~/helpers/platformutils"
+<script setup lang="ts">
+import { reactive, ref, useContext } from "@nuxtjs/composition-api"
+import { useCodemirror } from "~/helpers/editor/codemirror"
 import { copyToClipboard } from "~/helpers/utils/clipboard"
 import { useReadonlyStream } from "~/helpers/utils/composables"
 import { gqlResponse$ } from "~/newstore/GQLSession"
 
-export default defineComponent({
-  props: {
-    conn: {
-      type: Object as PropType<GQLConnection>,
-      required: true,
-    },
-  },
-  setup() {
-    const {
-      $toast,
-      app: { i18n },
-    } = useContext()
-    const t = i18n.t.bind(i18n)
-
-    const responseString = useReadonlyStream(gqlResponse$, "")
+const {
+  $toast,
+  app: { i18n },
+} = useContext()
+const t = i18n.t.bind(i18n)
 
-    const downloadResponseIcon = ref("download")
-    const copyResponseIcon = ref("copy")
+const responseString = useReadonlyStream(gqlResponse$, "")
 
-    const copyResponse = () => {
-      copyToClipboard(responseString.value!)
-      copyResponseIcon.value = "check"
-      setTimeout(() => (copyResponseIcon.value = "copy"), 1000)
-    }
+const schemaEditor = ref<any | null>(null)
+const linewrapEnabled = ref(true)
 
-    const downloadResponse = () => {
-      const dataToWrite = responseString.value
-      const file = new Blob([dataToWrite!], { type: "application/json" })
-      const a = document.createElement("a")
-      const url = URL.createObjectURL(file)
-      a.href = url
-      a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}`
-      document.body.appendChild(a)
-      a.click()
-      downloadResponseIcon.value = "check"
-      $toast.success(t("state.download_started").toString(), {
-        icon: "downloading",
-      })
-      setTimeout(() => {
-        document.body.removeChild(a)
-        URL.revokeObjectURL(url)
-        downloadResponseIcon.value = "download"
-      }, 1000)
-    }
-
-    return {
-      responseString,
+useCodemirror(
+  schemaEditor,
+  responseString,
+  reactive({
+    extendedEditorConfig: {
+      mode: "application/ld+json",
+      readOnly: true,
+      lineWrapping: linewrapEnabled,
+    },
+    linter: null,
+    completer: null,
+  })
+)
 
-      downloadResponseIcon,
-      copyResponseIcon,
+const downloadResponseIcon = ref("download")
+const copyResponseIcon = ref("copy")
 
-      downloadResponse,
-      copyResponse,
+const copyResponse = () => {
+  copyToClipboard(responseString.value!)
+  copyResponseIcon.value = "check"
+  setTimeout(() => (copyResponseIcon.value = "copy"), 1000)
+}
 
-      getSpecialKey: getPlatformSpecialKey,
-    }
-  },
-})
+const downloadResponse = () => {
+  const dataToWrite = responseString.value
+  const file = new Blob([dataToWrite!], { type: "application/json" })
+  const a = document.createElement("a")
+  const url = URL.createObjectURL(file)
+  a.href = url
+  a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}`
+  document.body.appendChild(a)
+  a.click()
+  downloadResponseIcon.value = "check"
+  $toast.success(t("state.download_started").toString(), {
+    icon: "downloading",
+  })
+  setTimeout(() => {
+    document.body.removeChild(a)
+    URL.revokeObjectURL(url)
+    downloadResponseIcon.value = "download"
+  }, 1000)
+}
 </script>
 
 <style lang="scss" scoped>

+ 159 - 182
packages/hoppscotch-app/components/graphql/Sidebar.vue

@@ -149,6 +149,13 @@
                 :title="$t('app.wiki')"
                 svg="help-circle"
               />
+              <ButtonSecondary
+                v-tippy="{ theme: 'tooltip' }"
+                :title="$t('state.linewrap')"
+                :class="{ '!text-accent': linewrapEnabled }"
+                svg="corner-down-left"
+                @click.native.prevent="linewrapEnabled = !linewrapEnabled"
+              />
               <ButtonSecondary
                 ref="downloadSchema"
                 v-tippy="{ theme: 'tooltip' }"
@@ -165,20 +172,7 @@
               />
             </div>
           </div>
-          <SmartAceEditor
-            v-if="schemaString"
-            v-model="schemaString"
-            :lang="'graphqlschema'"
-            :options="{
-              maxLines: Infinity,
-              minLines: 16,
-              autoScrollEditorIntoView: true,
-              readOnly: true,
-              showPrintMargin: false,
-              useWorker: false,
-            }"
-            styles="border-b border-dividerLight"
-          />
+          <div v-if="schemaString" ref="schemaEditor"></div>
           <div
             v-else
             class="
@@ -200,17 +194,17 @@
   </aside>
 </template>
 
-<script lang="ts">
+<script setup lang="ts">
 import {
   computed,
-  defineComponent,
   nextTick,
-  PropType,
+  reactive,
   ref,
   useContext,
 } from "@nuxtjs/composition-api"
 import { GraphQLField, GraphQLType } from "graphql"
 import { map } from "rxjs/operators"
+import { useCodemirror } from "~/helpers/editor/codemirror"
 import { GQLConnection } from "~/helpers/GQLConnection"
 import { GQLHeader } from "~/helpers/types/HoppGQLRequest"
 import { copyToClipboard } from "~/helpers/utils/clipboard"
@@ -222,6 +216,7 @@ import {
   setGQLURL,
   setGQLVariables,
 } from "~/newstore/GQLSession"
+import "~/helpers/editor/modes/graphql"
 
 function isTextFoundInGraphqlFieldObject(
   text: string,
@@ -285,186 +280,168 @@ type GQLHistoryEntry = {
   variables: string
 }
 
-export default defineComponent({
-  props: {
-    conn: {
-      type: Object as PropType<GQLConnection>,
-      required: true,
-    },
-  },
-  setup(props) {
-    const {
-      $toast,
-      app: { i18n },
-    } = useContext()
-    const t = i18n.t.bind(i18n)
-
-    const queryFields = useReadonlyStream(
-      props.conn.queryFields$.pipe(map((x) => x ?? [])),
-      []
-    )
-    const mutationFields = useReadonlyStream(
-      props.conn.mutationFields$.pipe(map((x) => x ?? [])),
-      []
-    )
-    const subscriptionFields = useReadonlyStream(
-      props.conn.subscriptionFields$.pipe(map((x) => x ?? [])),
-      []
-    )
-    const graphqlTypes = useReadonlyStream(
-      props.conn.graphqlTypes$.pipe(map((x) => x ?? [])),
-      []
-    )
+const props = defineProps<{
+  conn: GQLConnection
+}>()
 
-    const downloadSchemaIcon = ref("download")
-    const copySchemaIcon = ref("copy")
-
-    const graphqlFieldsFilterText = ref("")
-
-    const gqlTabs = ref<any | null>(null)
-    const typesTab = ref<any | null>(null)
-
-    const filteredQueryFields = computed(() => {
-      return getFilteredGraphqlFields(
-        graphqlFieldsFilterText.value,
-        queryFields.value as any
-      )
-    })
-
-    const filteredMutationFields = computed(() => {
-      return getFilteredGraphqlFields(
-        graphqlFieldsFilterText.value,
-        mutationFields.value as any
-      )
-    })
-
-    const filteredSubscriptionFields = computed(() => {
-      return getFilteredGraphqlFields(
-        graphqlFieldsFilterText.value,
-        subscriptionFields.value as any
-      )
-    })
-
-    const filteredGraphqlTypes = computed(() => {
-      return getFilteredGraphqlTypes(
-        graphqlFieldsFilterText.value,
-        graphqlTypes.value as any
-      )
-    })
-
-    const isGqlTypeHighlighted = (gqlType: GraphQLType) => {
-      if (!graphqlFieldsFilterText.value) return false
-
-      return isTextFoundInGraphqlFieldObject(
-        graphqlFieldsFilterText.value,
-        gqlType as any
-      )
-    }
+const {
+  $toast,
+  app: { i18n },
+} = useContext()
+const t = i18n.t.bind(i18n)
 
-    const getGqlTypeHighlightedFields = (gqlType: GraphQLType) => {
-      if (!graphqlFieldsFilterText.value) return []
+const queryFields = useReadonlyStream(
+  props.conn.queryFields$.pipe(map((x) => x ?? [])),
+  []
+)
 
-      const fields = Object.values((gqlType as any)._fields || {})
-      if (!fields || fields.length === 0) return []
+const mutationFields = useReadonlyStream(
+  props.conn.mutationFields$.pipe(map((x) => x ?? [])),
+  []
+)
 
-      return fields.filter((field) =>
-        isTextFoundInGraphqlFieldObject(
-          graphqlFieldsFilterText.value,
-          field as any
-        )
-      )
-    }
+const subscriptionFields = useReadonlyStream(
+  props.conn.subscriptionFields$.pipe(map((x) => x ?? [])),
+  []
+)
 
-    const handleJumpToType = async (type: GraphQLType) => {
-      gqlTabs.value.selectTab(typesTab.value)
-      await nextTick()
+const graphqlTypes = useReadonlyStream(
+  props.conn.graphqlTypes$.pipe(map((x) => x ?? [])),
+  []
+)
 
-      const rootTypeName = resolveRootType(type).name
+const downloadSchemaIcon = ref("download")
+const copySchemaIcon = ref("copy")
 
-      const target = document.getElementById(`type_${rootTypeName}`)
-      if (target) {
-        gqlTabs.value.$el
-          .querySelector(".gqlTabs")
-          .scrollTo({ top: target.offsetTop, behavior: "smooth" })
-      }
-    }
-    const schemaString = useReadonlyStream(
-      props.conn.schemaString$.pipe(map((x) => x ?? "")),
-      ""
-    )
+const graphqlFieldsFilterText = ref("")
 
-    const downloadSchema = () => {
-      const dataToWrite = JSON.stringify(schemaString.value, null, 2)
-      const file = new Blob([dataToWrite], { type: "application/graphql" })
-      const a = document.createElement("a")
-      const url = URL.createObjectURL(file)
-      a.href = url
-      a.download = `${
-        url.split("/").pop()!.split("#")[0].split("?")[0]
-      }.graphql`
-      document.body.appendChild(a)
-      a.click()
-      downloadSchemaIcon.value = "check"
-      $toast.success(t("state.download_started").toString(), {
-        icon: "downloading",
-      })
-      setTimeout(() => {
-        document.body.removeChild(a)
-        URL.revokeObjectURL(url)
-        downloadSchemaIcon.value = "download"
-      }, 1000)
-    }
+const gqlTabs = ref<any | null>(null)
+const typesTab = ref<any | null>(null)
 
-    const copySchema = () => {
-      if (!schemaString.value) return
+const filteredQueryFields = computed(() => {
+  return getFilteredGraphqlFields(
+    graphqlFieldsFilterText.value,
+    queryFields.value as any
+  )
+})
 
-      copyToClipboard(schemaString.value)
-      copySchemaIcon.value = "check"
-      setTimeout(() => (copySchemaIcon.value = "copy"), 1000)
-    }
+const filteredMutationFields = computed(() => {
+  return getFilteredGraphqlFields(
+    graphqlFieldsFilterText.value,
+    mutationFields.value as any
+  )
+})
 
-    const handleUseHistory = (entry: GQLHistoryEntry) => {
-      const url = entry.url
-      const headers = entry.headers
-      const gqlQueryString = entry.query
-      const variableString = entry.variables
-      const responseText = entry.response
-
-      setGQLURL(url)
-      setGQLHeaders(headers)
-      setGQLQuery(gqlQueryString)
-      setGQLVariables(variableString)
-      setGQLResponse(responseText)
-      props.conn.reset()
-    }
+const filteredSubscriptionFields = computed(() => {
+  return getFilteredGraphqlFields(
+    graphqlFieldsFilterText.value,
+    subscriptionFields.value as any
+  )
+})
 
-    return {
-      queryFields,
-      mutationFields,
-      subscriptionFields,
-      graphqlTypes,
-      schemaString,
+const filteredGraphqlTypes = computed(() => {
+  return getFilteredGraphqlTypes(
+    graphqlFieldsFilterText.value,
+    graphqlTypes.value as any
+  )
+})
 
-      graphqlFieldsFilterText,
+const isGqlTypeHighlighted = (gqlType: GraphQLType) => {
+  if (!graphqlFieldsFilterText.value) return false
 
-      filteredQueryFields,
-      filteredMutationFields,
-      filteredSubscriptionFields,
-      filteredGraphqlTypes,
+  return isTextFoundInGraphqlFieldObject(
+    graphqlFieldsFilterText.value,
+    gqlType as any
+  )
+}
 
-      isGqlTypeHighlighted,
-      getGqlTypeHighlightedFields,
+const getGqlTypeHighlightedFields = (gqlType: GraphQLType) => {
+  if (!graphqlFieldsFilterText.value) return []
 
-      gqlTabs,
-      typesTab,
-      handleJumpToType,
+  const fields = Object.values((gqlType as any)._fields || {})
+  if (!fields || fields.length === 0) return []
 
-      downloadSchema,
-      downloadSchemaIcon,
-      copySchemaIcon,
-      copySchema,
-      handleUseHistory,
-    }
-  },
-})
+  return fields.filter((field) =>
+    isTextFoundInGraphqlFieldObject(graphqlFieldsFilterText.value, field as any)
+  )
+}
+
+const handleJumpToType = async (type: GraphQLType) => {
+  gqlTabs.value.selectTab(typesTab.value)
+  await nextTick()
+
+  const rootTypeName = resolveRootType(type).name
+
+  const target = document.getElementById(`type_${rootTypeName}`)
+  if (target) {
+    gqlTabs.value.$el
+      .querySelector(".gqlTabs")
+      .scrollTo({ top: target.offsetTop, behavior: "smooth" })
+  }
+}
+
+const schemaString = useReadonlyStream(
+  props.conn.schemaString$.pipe(map((x) => x ?? "")),
+  ""
+)
+
+const schemaEditor = ref<any | null>(null)
+const linewrapEnabled = ref(true)
+
+useCodemirror(
+  schemaEditor,
+  schemaString,
+  reactive({
+    extendedEditorConfig: {
+      mode: "graphql",
+      readOnly: true,
+      lineWrapping: linewrapEnabled,
+    },
+    linter: null,
+    completer: null,
+  })
+)
+
+const downloadSchema = () => {
+  const dataToWrite = JSON.stringify(schemaString.value, null, 2)
+  const file = new Blob([dataToWrite], { type: "application/graphql" })
+  const a = document.createElement("a")
+  const url = URL.createObjectURL(file)
+  a.href = url
+  a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.graphql`
+  document.body.appendChild(a)
+  a.click()
+  downloadSchemaIcon.value = "check"
+  $toast.success(t("state.download_started").toString(), {
+    icon: "downloading",
+  })
+  setTimeout(() => {
+    document.body.removeChild(a)
+    URL.revokeObjectURL(url)
+    downloadSchemaIcon.value = "download"
+  }, 1000)
+}
+
+const copySchema = () => {
+  if (!schemaString.value) return
+
+  copyToClipboard(schemaString.value)
+  copySchemaIcon.value = "check"
+  setTimeout(() => (copySchemaIcon.value = "copy"), 1000)
+}
+
+const handleUseHistory = (entry: GQLHistoryEntry) => {
+  const url = entry.url
+  const headers = entry.headers
+  const gqlQueryString = entry.query
+  const variableString = entry.variables
+  const responseText = entry.response
+
+  setGQLURL(url)
+  setGQLHeaders(headers)
+  setGQLQuery(gqlQueryString)
+  setGQLVariables(variableString)
+  setGQLResponse(responseText)
+  props.conn.reset()
+}
 </script>

+ 26 - 20
packages/hoppscotch-app/components/http/CodegenModal.vue

@@ -38,31 +38,25 @@
             {{ t("request.generated_code") }}
           </label>
         </div>
-        <SmartAceEditor
+        <div
           v-if="codegenType"
           ref="generatedCode"
-          :value="requestCode"
-          :lang="codegens.find((x) => x.id === codegenType).language"
-          :options="{
-            maxLines: 16,
-            minLines: 8,
-            autoScrollEditorIntoView: true,
-            readOnly: true,
-            showPrintMargin: false,
-            useWorker: false,
-          }"
-          styles="border rounded border-dividerLight"
-        />
+          class="border border-dividerLight rounded"
+        ></div>
       </div>
     </template>
     <template #footer>
-      <ButtonPrimary
-        ref="copyRequestCode"
-        :label="t('action.copy')"
-        :svg="copyIcon"
-        @click.native="copyRequestCode"
-      />
-      <ButtonSecondary :label="t('action.dismiss')" @click.native="hideModal" />
+      <span class="flex">
+        <ButtonPrimary
+          :label="t('action.copy').toString()"
+          :svg="copyIcon"
+          @click.native="copyRequestCode"
+        />
+        <ButtonSecondary
+          :label="t('action.dismiss').toString()"
+          @click.native="hideModal"
+        />
+      </span>
     </template>
   </SmartModal>
 </template>
@@ -70,6 +64,7 @@
 <script setup lang="ts">
 import { computed, ref, useContext, watch } from "@nuxtjs/composition-api"
 import { codegens, generateCodegenContext } from "~/helpers/codegen/codegen"
+import { useCodemirror } from "~/helpers/editor/codemirror"
 import { copyToClipboard } from "~/helpers/utils/clipboard"
 import { getEffectiveRESTRequest } from "~/helpers/utils/EffectiveURL"
 import { getCurrentEnvironment } from "~/newstore/environments"
@@ -106,6 +101,17 @@ const requestCode = computed(() => {
     .generator(generateCodegenContext(effectiveRequest))
 })
 
+const generatedCode = ref<any | null>(null)
+
+useCodemirror(generatedCode, requestCode, {
+  extendedEditorConfig: {
+    mode: "text/plain",
+    readOnly: true,
+  },
+  linter: null,
+  completer: null,
+})
+
 watch(
   () => props.show,
   (goingToShow) => {

+ 68 - 98
packages/hoppscotch-app/components/http/Headers.vue

@@ -47,27 +47,7 @@
         />
       </div>
     </div>
-    <div v-if="bulkMode" class="flex">
-      <textarea-autosize
-        v-model="bulkHeaders"
-        v-focus
-        name="bulk-headers"
-        class="
-          bg-transparent
-          border-b border-dividerLight
-          flex
-          font-mono
-          flex-1
-          py-2
-          px-4
-          whitespace-pre
-          resize-y
-          overflow-auto
-        "
-        rows="10"
-        :placeholder="$t('state.bulk_mode_placeholder')"
-      />
-    </div>
+    <div v-if="bulkMode" ref="bulkEditor"></div>
     <div v-else>
       <div
         v-for="(header, index) in headers$"
@@ -193,96 +173,86 @@
   </AppSection>
 </template>
 
-<script lang="ts">
+<script setup lang="ts">
+import { ref, useContext, watch } from "@nuxtjs/composition-api"
+import { useCodemirror } from "~/helpers/editor/codemirror"
 import {
-  defineComponent,
-  ref,
-  useContext,
-  watch,
-} from "@nuxtjs/composition-api"
-import {
-  restHeaders$,
   addRESTHeader,
-  updateRESTHeader,
-  deleteRESTHeader,
   deleteAllRESTHeaders,
+  deleteRESTHeader,
+  restHeaders$,
   setRESTHeaders,
+  updateRESTHeader,
 } from "~/newstore/RESTSession"
 import { commonHeaders } from "~/helpers/headers"
 import { useSetting } from "~/newstore/settings"
 import { useReadonlyStream } from "~/helpers/utils/composables"
 import { HoppRESTHeader } from "~/helpers/types/HoppRESTRequest"
 
-export default defineComponent({
-  setup() {
-    const {
-      $toast,
-      app: { i18n },
-    } = useContext()
-    const t = i18n.t.bind(i18n)
+const {
+  $toast,
+  app: { i18n },
+} = useContext()
+const t = i18n.t.bind(i18n)
 
-    const bulkMode = ref(false)
-    const bulkHeaders = ref("")
+const bulkMode = ref(false)
+const bulkHeaders = ref("")
+const bulkEditor = ref<any | null>(null)
 
-    watch(bulkHeaders, () => {
-      try {
-        const transformation = bulkHeaders.value.split("\n").map((item) => ({
-          key: item.substring(0, item.indexOf(":")).trim().replace(/^\/\//, ""),
-          value: item.substring(item.indexOf(":") + 1).trim(),
-          active: !item.trim().startsWith("//"),
-        }))
-        setRESTHeaders(transformation)
-      } catch (e) {
-        $toast.error(t("error.something_went_wrong").toString(), {
-          icon: "error_outline",
-        })
-        console.error(e)
-      }
+useCodemirror(bulkEditor, bulkHeaders, {
+  extendedEditorConfig: {
+    mode: "text/x-yaml",
+    placeholder: t("state.bulk_mode_placeholder").toString(),
+  },
+  linter: null,
+  completer: null,
+})
+
+watch(bulkHeaders, () => {
+  try {
+    const transformation = bulkHeaders.value.split("\n").map((item) => ({
+      key: item.substring(0, item.indexOf(":")).trim().replace(/^\/\//, ""),
+      value: item.substring(item.indexOf(":") + 1).trim(),
+      active: !item.trim().startsWith("//"),
+    }))
+    setRESTHeaders(transformation)
+  } catch (e) {
+    $toast.error(t("error.something_went_wrong").toString(), {
+      icon: "error_outline",
     })
+    console.error(e)
+  }
+})
 
-    return {
-      headers$: useReadonlyStream(restHeaders$, []),
-      EXPERIMENTAL_URL_BAR_ENABLED: useSetting("EXPERIMENTAL_URL_BAR_ENABLED"),
-      bulkMode,
-      bulkHeaders,
-    }
-  },
-  data() {
-    return {
-      commonHeaders,
-    }
-  },
-  watch: {
-    headers$: {
-      handler(newValue) {
-        if (
-          (newValue[newValue.length - 1]?.key !== "" ||
-            newValue[newValue.length - 1]?.value !== "") &&
-          newValue.length
-        )
-          this.addHeader()
-      },
-      deep: true,
-    },
-  },
-  // mounted() {
-  //   if (!this.headers$?.length) {
-  //     this.addHeader()
-  //   }
-  // },
-  methods: {
-    addHeader() {
-      addRESTHeader({ key: "", value: "", active: true })
-    },
-    updateHeader(index: number, item: HoppRESTHeader) {
-      updateRESTHeader(index, item)
-    },
-    deleteHeader(index: number) {
-      deleteRESTHeader(index)
-    },
-    clearContent() {
-      deleteAllRESTHeaders()
-    },
+const headers$ = useReadonlyStream(restHeaders$, [])
+
+watch(
+  headers$,
+  (newValue) => {
+    if (
+      (newValue[newValue.length - 1]?.key !== "" ||
+        newValue[newValue.length - 1]?.value !== "") &&
+      newValue.length
+    )
+      addHeader()
   },
-})
+  { deep: true }
+)
+
+const addHeader = () => {
+  addRESTHeader({ key: "", value: "", active: true })
+}
+
+const updateHeader = (index: number, item: HoppRESTHeader) => {
+  updateRESTHeader(index, item)
+}
+
+const deleteHeader = (index: number) => {
+  deleteRESTHeader(index)
+}
+
+const clearContent = () => {
+  deleteAllRESTHeaders()
+}
+const EXPERIMENTAL_URL_BAR_ENABLED = useSetting("EXPERIMENTAL_URL_BAR_ENABLED")
 </script>

+ 103 - 103
packages/hoppscotch-app/components/http/ImportCurl.vue

@@ -1,28 +1,22 @@
 <template>
-  <SmartModal v-if="show" :title="$t('import.curl')" @close="hideModal">
+  <SmartModal
+    v-if="show"
+    :title="$t('import.curl').toString()"
+    @close="hideModal"
+  >
     <template #body>
       <div class="flex flex-col px-2">
-        <textarea-autosize
-          id="import-curl"
-          v-model="curl"
-          class="font-mono textarea floating-input"
-          autofocus
-          rows="8"
-          placeholder=" "
-        />
-        <label for="import-curl">
-          {{ $t("request.enter_curl") }}
-        </label>
+        <div ref="curlEditor" class="border border-dividerLight rounded"></div>
       </div>
     </template>
     <template #footer>
-      <span>
+      <span class="flex">
         <ButtonPrimary
-          :label="$t('import.title')"
+          :label="$t('import.title').toString()"
           @click.native="handleImport"
         />
         <ButtonSecondary
-          :label="$t('action.cancel')"
+          :label="$t('action.cancel').toString()"
           @click.native="hideModal"
         />
       </span>
@@ -30,108 +24,114 @@
   </SmartModal>
 </template>
 
-<script lang="ts">
-import { defineComponent } from "@nuxtjs/composition-api"
+<script setup lang="ts">
+import { ref, useContext } from "@nuxtjs/composition-api"
 import parseCurlCommand from "~/helpers/curlparser"
+import { useCodemirror } from "~/helpers/editor/codemirror"
 import {
   HoppRESTHeader,
   HoppRESTParam,
   makeRESTRequest,
 } from "~/helpers/types/HoppRESTRequest"
 import { setRESTRequest } from "~/newstore/RESTSession"
+import "codemirror/mode/shell/shell"
 
-export default defineComponent({
-  props: {
-    show: Boolean,
-  },
-  emits: ["hide-modal"],
-  data() {
-    return {
-      curl: "",
-    }
+const {
+  $toast,
+  app: { i18n },
+} = useContext()
+const t = i18n.t.bind(i18n)
+
+const curl = ref("")
+
+const curlEditor = ref<any | null>(null)
+
+useCodemirror(curlEditor, curl, {
+  extendedEditorConfig: {
+    mode: "application/x-sh",
+    placeholder: t("request.enter_curl").toString(),
   },
-  methods: {
-    hideModal() {
-      this.$emit("hide-modal")
-    },
-    handleImport() {
-      const text = this.curl
-      try {
-        const parsedCurl = parseCurlCommand(text)
-        const { origin, pathname } = new URL(
-          parsedCurl.url.replace(/"/g, "").replace(/'/g, "")
-        )
-        const endpoint = origin + pathname
-        const headers: HoppRESTHeader[] = []
-        const params: HoppRESTParam[] = []
-        if (parsedCurl.query) {
-          for (const key of Object.keys(parsedCurl.query)) {
-            const val = parsedCurl.query[key]!
+  linter: null,
+  completer: null,
+})
 
-            if (Array.isArray(val)) {
-              val.forEach((value) => {
-                params.push({
-                  key,
-                  value,
-                  active: true,
-                })
-              })
-            } else {
-              params.push({
-                key,
-                value: val!,
-                active: true,
-              })
-            }
-          }
-        }
-        if (parsedCurl.headers) {
-          for (const key of Object.keys(parsedCurl.headers)) {
-            headers.push({
-              key,
-              value: parsedCurl.headers[key],
-              active: true,
-            })
-          }
-        }
-        const method = parsedCurl.method.toUpperCase()
-        // let rawInput = false
-        // let rawParams: any | null = null
+defineProps<{ show: boolean }>()
 
-        // if (parsedCurl.data) {
-        //   rawInput = true
-        //   rawParams = parsedCurl.data
-        // }
+const emit = defineEmits<{
+  (e: "hide-modal"): void
+}>()
 
-        this.showCurlImportModal = false
+const hideModal = () => {
+  emit("hide-modal")
+}
 
-        setRESTRequest(
-          makeRESTRequest({
-            name: "Untitled request",
-            endpoint,
-            method,
-            params,
-            headers,
-            preRequestScript: "",
-            testScript: "",
-            auth: {
-              authType: "none",
-              authActive: true,
-            },
-            body: {
-              contentType: "application/json",
-              body: "",
-            },
+const handleImport = () => {
+  const text = curl.value
+  try {
+    const parsedCurl = parseCurlCommand(text)
+    const { origin, pathname } = new URL(
+      parsedCurl.url.replace(/"/g, "").replace(/'/g, "")
+    )
+    const endpoint = origin + pathname
+    const headers: HoppRESTHeader[] = []
+    const params: HoppRESTParam[] = []
+    if (parsedCurl.query) {
+      for (const key of Object.keys(parsedCurl.query)) {
+        const val = parsedCurl.query[key]!
+
+        if (Array.isArray(val)) {
+          val.forEach((value) => {
+            params.push({
+              key,
+              value,
+              active: true,
+            })
+          })
+        } else {
+          params.push({
+            key,
+            value: val!,
+            active: true,
           })
-        )
-      } catch (e) {
-        console.error(e)
-        this.$toast.error(this.$t("error.curl_invalid_format").toString(), {
-          icon: "error_outline",
+        }
+      }
+    }
+    if (parsedCurl.headers) {
+      for (const key of Object.keys(parsedCurl.headers)) {
+        headers.push({
+          key,
+          value: parsedCurl.headers[key],
+          active: true,
         })
       }
-      this.hideModal()
-    },
-  },
-})
+    }
+    const method = parsedCurl.method.toUpperCase()
+
+    setRESTRequest(
+      makeRESTRequest({
+        name: "Untitled request",
+        endpoint,
+        method,
+        params,
+        headers,
+        preRequestScript: "",
+        testScript: "",
+        auth: {
+          authType: "none",
+          authActive: true,
+        },
+        body: {
+          contentType: "application/json",
+          body: "",
+        },
+      })
+    )
+  } catch (e) {
+    console.error(e)
+    $toast.error(t("error.curl_invalid_format").toString(), {
+      icon: "error_outline",
+    })
+  }
+  hideModal()
+}
 </script>

+ 69 - 91
packages/hoppscotch-app/components/http/Parameters.vue

@@ -47,27 +47,7 @@
         />
       </div>
     </div>
-    <div v-if="bulkMode" class="flex">
-      <textarea-autosize
-        v-model="bulkParams"
-        v-focus
-        name="bulk-parameters"
-        class="
-          bg-transparent
-          border-b border-dividerLight
-          flex
-          font-mono font-medium
-          flex-1
-          py-2
-          px-4
-          whitespace-pre
-          resize-y
-          overflow-auto
-        "
-        rows="10"
-        :placeholder="$t('state.bulk_mode_placeholder')"
-      />
-    </div>
+    <div v-if="bulkMode" ref="bulkEditor"></div>
     <div v-else>
       <div
         v-for="(param, index) in params$"
@@ -96,7 +76,7 @@
         <input
           v-else
           class="bg-transparent flex flex-1 py-2 px-4"
-          :placeholder="$t('count.parameter', { count: index + 1 })"
+          :placeholder="$t('count.parameter', { count: index + 1 }).toString()"
           :name="'param' + index"
           :value="param.key"
           autofocus
@@ -130,7 +110,7 @@
         <input
           v-else
           class="bg-transparent flex flex-1 py-2 px-4"
-          :placeholder="$t('count.value', { count: index + 1 })"
+          :placeholder="$t('count.value', { count: index + 1 }).toString()"
           :name="'value' + index"
           :value="param.value"
           @change="
@@ -202,13 +182,9 @@
   </AppSection>
 </template>
 
-<script lang="ts">
-import {
-  defineComponent,
-  ref,
-  useContext,
-  watch,
-} from "@nuxtjs/composition-api"
+<script setup lang="ts">
+import { ref, useContext, watch } from "@nuxtjs/composition-api"
+import { useCodemirror } from "~/helpers/editor/codemirror"
 import { HoppRESTParam } from "~/helpers/types/HoppRESTRequest"
 import { useReadonlyStream } from "~/helpers/utils/composables"
 import {
@@ -220,72 +196,74 @@ import {
   setRESTParams,
 } from "~/newstore/RESTSession"
 import { useSetting } from "~/newstore/settings"
+import "codemirror/mode/yaml/yaml"
 
-export default defineComponent({
-  setup() {
-    const {
-      $toast,
-      app: { i18n },
-    } = useContext()
-    const t = i18n.t.bind(i18n)
+const {
+  $toast,
+  app: { i18n },
+} = useContext()
+const t = i18n.t.bind(i18n)
 
-    const bulkMode = ref(false)
-    const bulkParams = ref("")
+const bulkMode = ref(false)
+const bulkParams = ref("")
 
-    watch(bulkParams, () => {
-      try {
-        const transformation = bulkParams.value.split("\n").map((item) => ({
-          key: item.substring(0, item.indexOf(":")).trim().replace(/^\/\//, ""),
-          value: item.substring(item.indexOf(":") + 1).trim(),
-          active: !item.trim().startsWith("//"),
-        }))
-        setRESTParams(transformation)
-      } catch (e) {
-        $toast.error(t("error.something_went_wrong").toString(), {
-          icon: "error_outline",
-        })
-        console.error(e)
-      }
+watch(bulkParams, () => {
+  try {
+    const transformation = bulkParams.value.split("\n").map((item) => ({
+      key: item.substring(0, item.indexOf(":")).trim().replace(/^\/\//, ""),
+      value: item.substring(item.indexOf(":") + 1).trim(),
+      active: !item.trim().startsWith("//"),
+    }))
+    setRESTParams(transformation)
+  } catch (e) {
+    $toast.error(t("error.something_went_wrong").toString(), {
+      icon: "error_outline",
     })
+    console.error(e)
+  }
+})
 
-    return {
-      params$: useReadonlyStream(restParams$, []),
-      EXPERIMENTAL_URL_BAR_ENABLED: useSetting("EXPERIMENTAL_URL_BAR_ENABLED"),
-      bulkMode,
-      bulkParams,
-    }
-  },
-  watch: {
-    params$: {
-      handler(newValue) {
-        if (
-          (newValue[newValue.length - 1]?.key !== "" ||
-            newValue[newValue.length - 1]?.value !== "") &&
-          newValue.length
-        )
-          this.addParam()
-      },
-      deep: true,
-    },
-  },
-  // mounted() {
-  //   if (!this.params$?.length) {
-  //     this.addParam()
-  //   }
-  // },
-  methods: {
-    addParam() {
-      addRESTParam({ key: "", value: "", active: true })
-    },
-    updateParam(index: number, item: HoppRESTParam) {
-      updateRESTParam(index, item)
-    },
-    deleteParam(index: number) {
-      deleteRESTParam(index)
-    },
-    clearContent() {
-      deleteAllRESTParams()
-    },
+const bulkEditor = ref<any | null>(null)
+
+useCodemirror(bulkEditor, bulkParams, {
+  extendedEditorConfig: {
+    mode: "text/x-yaml",
+    placeholder: t("state.bulk_mode_placeholder").toString(),
   },
+  linter: null,
+  completer: null,
 })
+
+const params$ = useReadonlyStream(restParams$, [])
+
+watch(
+  params$,
+  (newValue) => {
+    if (
+      (newValue[newValue.length - 1]?.key !== "" ||
+        newValue[newValue.length - 1]?.value !== "") &&
+      newValue.length
+    )
+      addParam()
+  },
+  { deep: true }
+)
+
+const addParam = () => {
+  addRESTParam({ key: "", value: "", active: true })
+}
+
+const updateParam = (index: number, item: HoppRESTParam) => {
+  updateRESTParam(index, item)
+}
+
+const deleteParam = (index: number) => {
+  deleteRESTParam(index)
+}
+
+const clearContent = () => {
+  deleteAllRESTParams()
+}
+
+const EXPERIMENTAL_URL_BAR_ENABLED = useSetting("EXPERIMENTAL_URL_BAR_ENABLED")
 </script>

+ 43 - 31
packages/hoppscotch-app/components/http/PreRequestScript.vue

@@ -24,6 +24,13 @@
           :title="$t('app.wiki')"
           svg="help-circle"
         />
+        <ButtonSecondary
+          v-tippy="{ theme: 'tooltip' }"
+          :title="$t('state.linewrap')"
+          :class="{ '!text-accent': linewrapEnabled }"
+          svg="corner-down-left"
+          @click.native.prevent="linewrapEnabled = !linewrapEnabled"
+        />
         <ButtonSecondary
           v-tippy="{ theme: 'tooltip' }"
           :title="$t('action.clear')"
@@ -34,17 +41,7 @@
     </div>
     <div class="border-b border-dividerLight flex">
       <div class="border-r border-dividerLight w-2/3">
-        <SmartJsEditor
-          v-model="preRequestScript"
-          :options="{
-            maxLines: Infinity,
-            minLines: 16,
-            autoScrollEditorIntoView: true,
-            showPrintMargin: false,
-            useWorker: false,
-          }"
-          complete-mode="pre"
-        />
+        <div ref="preRrequestEditor"></div>
       </div>
       <div
         class="
@@ -84,29 +81,44 @@
   </AppSection>
 </template>
 
-<script lang="ts">
-import { defineComponent } from "@nuxtjs/composition-api"
+<script setup lang="ts">
+import { reactive, ref, useContext } from "@nuxtjs/composition-api"
 import { usePreRequestScript } from "~/newstore/RESTSession"
-import preRequestScriptSnippets from "~/helpers/preRequestScriptSnippets"
+import snippets from "~/helpers/preRequestScriptSnippets"
+import "codemirror/mode/javascript/javascript"
+import { useCodemirror } from "~/helpers/editor/codemirror"
+import linter from "~/helpers/editor/linting/preRequest"
+import completer from "~/helpers/editor/completion/preRequest"
+
+const {
+  app: { i18n },
+} = useContext()
+const t = i18n.t.bind(i18n)
+
+const preRequestScript = usePreRequestScript()
 
-export default defineComponent({
-  setup() {
-    const preRequestScript = usePreRequestScript()
+const preRrequestEditor = ref<any | null>(null)
+const linewrapEnabled = ref(true)
 
-    const useSnippet = (script: string) => {
-      preRequestScript.value += script
-    }
+useCodemirror(
+  preRrequestEditor,
+  preRequestScript,
+  reactive({
+    extendedEditorConfig: {
+      mode: "application/javascript",
+      lineWrapping: linewrapEnabled,
+      placeholder: t("preRequest.javascript_code").toString(),
+    },
+    linter,
+    completer,
+  })
+)
 
-    const clearContent = () => {
-      preRequestScript.value = ""
-    }
+const useSnippet = (script: string) => {
+  preRequestScript.value += script
+}
 
-    return {
-      preRequestScript,
-      snippets: preRequestScriptSnippets,
-      useSnippet,
-      clearContent,
-    }
-  },
-})
+const clearContent = () => {
+  preRequestScript.value = ""
+}
 </script>

+ 81 - 69
packages/hoppscotch-app/components/http/RawBody.vue

@@ -24,6 +24,13 @@
           :title="$t('app.wiki')"
           svg="help-circle"
         />
+        <ButtonSecondary
+          v-tippy="{ theme: 'tooltip' }"
+          :title="$t('state.linewrap')"
+          :class="{ '!text-accent': linewrapEnabled }"
+          svg="corner-down-left"
+          @click.native.prevent="linewrapEnabled = !linewrapEnabled"
+        />
         <ButtonSecondary
           v-tippy="{ theme: 'tooltip' }"
           :title="$t('action.clear')"
@@ -55,82 +62,87 @@
         />
       </div>
     </div>
-    <div class="relative">
-      <SmartAceEditor
-        v-model="rawParamsBody"
-        :lang="rawInputEditorLang"
-        :options="{
-          maxLines: Infinity,
-          minLines: 16,
-          autoScrollEditorIntoView: true,
-          showPrintMargin: false,
-          useWorker: false,
-        }"
-        styles="border-b border-dividerLight"
-      />
-    </div>
+    <div ref="rawBodyParameters"></div>
   </div>
 </template>
 
-<script>
-import { defineComponent } from "@nuxtjs/composition-api"
+<script setup lang="ts">
+import { computed, reactive, ref, useContext } from "@nuxtjs/composition-api"
+import { useCodemirror } from "~/helpers/editor/codemirror"
 import { getEditorLangForMimeType } from "~/helpers/editorutils"
 import { pluckRef } from "~/helpers/utils/composables"
 import { useRESTRequestBody } from "~/newstore/RESTSession"
+import "codemirror/mode/yaml/yaml"
+import "codemirror/mode/xml/xml"
+import "codemirror/mode/css/css"
+import "codemirror/mode/htmlmixed/htmlmixed"
+import "codemirror/mode/javascript/javascript"
+
+const props = defineProps<{
+  contentType: string
+}>()
+
+const {
+  $toast,
+  app: { i18n },
+} = useContext()
+const t = i18n.t.bind(i18n)
+
+const rawParamsBody = pluckRef(useRESTRequestBody(), "body")
+const prettifyIcon = ref("align-left")
 
-export default defineComponent({
-  props: {
-    contentType: {
-      type: String,
-      required: true,
+const rawInputEditorLang = computed(() =>
+  getEditorLangForMimeType(props.contentType)
+)
+const linewrapEnabled = ref(true)
+const rawBodyParameters = ref<any | null>(null)
+
+useCodemirror(
+  rawBodyParameters,
+  rawParamsBody,
+  reactive({
+    extendedEditorConfig: {
+      lineWrapping: linewrapEnabled,
+      mode: rawInputEditorLang,
+      placeholder: t("request.raw_body").toString(),
     },
-  },
-  setup() {
-    return {
-      rawParamsBody: pluckRef(useRESTRequestBody(), "body"),
-      prettifyIcon: "align-left",
+    linter: null,
+    completer: null,
+  })
+)
+
+const clearContent = () => {
+  rawParamsBody.value = ""
+}
+
+const uploadPayload = (e: InputEvent) => {
+  const file = e.target.files[0]
+  if (file !== undefined && file !== null) {
+    const reader = new FileReader()
+    reader.onload = ({ target }) => {
+      rawParamsBody.value = target?.result
     }
-  },
-  computed: {
-    rawInputEditorLang() {
-      return getEditorLangForMimeType(this.contentType)
-    },
-  },
-  methods: {
-    clearContent() {
-      this.rawParamsBody = ""
-    },
-    uploadPayload() {
-      const file = this.$refs.payload.files[0]
-      if (file !== undefined && file !== null) {
-        const reader = new FileReader()
-        reader.onload = ({ target }) => {
-          this.rawParamsBody = target.result
-        }
-        reader.readAsText(file)
-        this.$toast.success(this.$t("state.file_imported"), {
-          icon: "attach_file",
-        })
-      } else {
-        this.$toast.error(this.$t("action.choose_file"), {
-          icon: "attach_file",
-        })
-      }
-      this.$refs.payload.value = ""
-    },
-    prettifyRequestBody() {
-      try {
-        const jsonObj = JSON.parse(this.rawParamsBody)
-        this.rawParamsBody = JSON.stringify(jsonObj, null, 2)
-        this.prettifyIcon = "check"
-        setTimeout(() => (this.prettifyIcon = "align-left"), 1000)
-      } catch (e) {
-        console.error(e)
-        this.$toast.error(`${this.$t("error.json_prettify_invalid_body")}`, {
-          icon: "error_outline",
-        })
-      }
-    },
-  },
-})
+    reader.readAsText(file)
+    $toast.success(t("state.file_imported").toString(), {
+      icon: "attach_file",
+    })
+  } else {
+    $toast.error(t("action.choose_file").toString(), {
+      icon: "attach_file",
+    })
+  }
+}
+const prettifyRequestBody = () => {
+  try {
+    const jsonObj = JSON.parse(rawParamsBody.value)
+    rawParamsBody.value = JSON.stringify(jsonObj, null, 2)
+    prettifyIcon.value = "check"
+    setTimeout(() => (prettifyIcon.value = "align-left"), 1000)
+  } catch (e) {
+    console.error(e)
+    $toast.error(`${t("error.json_prettify_invalid_body")}`, {
+      icon: "error_outline",
+    })
+  }
+}
 </script>

+ 20 - 23
packages/hoppscotch-app/components/http/ResponseMeta.vue

@@ -10,19 +10,19 @@
       "
     >
       <div class="flex space-x-2 pb-4">
-        <div class="flex flex-col space-y-4 items-end">
+        <div class="flex flex-col space-y-4 text-right items-end">
           <span class="flex flex-1 items-center">
             {{ $t("shortcut.request.send_request") }}
           </span>
           <span class="flex flex-1 items-center">
             {{ $t("shortcut.general.show_all") }}
           </span>
-          <!-- <span class="flex flex-1 items-center">
+          <span class="flex flex-1 items-center">
             {{ $t("shortcut.general.command_menu") }}
           </span>
           <span class="flex flex-1 items-center">
             {{ $t("shortcut.general.help_menu") }}
-          </span> -->
+          </span>
         </div>
         <div class="flex flex-col space-y-4">
           <div class="flex">
@@ -33,12 +33,12 @@
             <span class="shortcut-key">{{ getSpecialKey() }}</span>
             <span class="shortcut-key">K</span>
           </div>
-          <!-- <div class="flex">
+          <div class="flex">
             <span class="shortcut-key">/</span>
           </div>
           <div class="flex">
             <span class="shortcut-key">?</span>
-          </div> -->
+          </div>
         </div>
       </div>
       <ButtonSecondary
@@ -102,26 +102,23 @@
   </div>
 </template>
 
-<script>
-import { defineComponent } from "@nuxtjs/composition-api"
+<script setup lang="ts">
+import { computed } from "@nuxtjs/composition-api"
 import findStatusGroup from "~/helpers/findStatusGroup"
-import { getPlatformSpecialKey } from "~/helpers/platformutils"
+import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
+import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
+
+const props = defineProps<{
+  response: HoppRESTResponse
+}>()
 
-export default defineComponent({
-  props: {
-    response: {
-      type: Object,
-      default: () => null,
-    },
-  },
-  computed: {
-    statusCategory() {
-      return findStatusGroup(this.response.statusCode)
-    },
-  },
-  methods: {
-    getSpecialKey: getPlatformSpecialKey,
-  },
+const statusCategory = computed(() => {
+  if (
+    props.response.type === "loading" ||
+    props.response.type === "network_fail"
+  )
+    return ""
+  return findStatusGroup(props.response.statusCode)
 })
 </script>
 

+ 35 - 11
packages/hoppscotch-app/components/http/Tests.vue

@@ -24,6 +24,13 @@
           :title="$t('app.wiki')"
           svg="help-circle"
         />
+        <ButtonSecondary
+          v-tippy="{ theme: 'tooltip' }"
+          :title="$t('state.linewrap')"
+          :class="{ '!text-accent': linewrapEnabled }"
+          svg="corner-down-left"
+          @click.native.prevent="linewrapEnabled = !linewrapEnabled"
+        />
         <ButtonSecondary
           v-tippy="{ theme: 'tooltip' }"
           :title="$t('action.clear')"
@@ -34,17 +41,7 @@
     </div>
     <div class="border-b border-dividerLight flex">
       <div class="border-r border-dividerLight w-2/3">
-        <SmartJsEditor
-          v-model="testScript"
-          :options="{
-            maxLines: Infinity,
-            minLines: 16,
-            autoScrollEditorIntoView: true,
-            showPrintMargin: false,
-            useWorker: false,
-          }"
-          complete-mode="test"
-        />
+        <div ref="testScriptEditor"></div>
       </div>
       <div
         class="
@@ -85,11 +82,38 @@
 </template>
 
 <script setup lang="ts">
+import { reactive, ref, useContext } from "@nuxtjs/composition-api"
 import { useTestScript } from "~/newstore/RESTSession"
 import testSnippets from "~/helpers/testSnippets"
+import "codemirror/mode/javascript/javascript"
+import { useCodemirror } from "~/helpers/editor/codemirror"
+import linter from "~/helpers/editor/linting/testScript"
+import completer from "~/helpers/editor/completion/testScript"
+
+const {
+  app: { i18n },
+} = useContext()
+const t = i18n.t.bind(i18n)
 
 const testScript = useTestScript()
 
+const testScriptEditor = ref<any | null>(null)
+const linewrapEnabled = ref(true)
+
+useCodemirror(
+  testScriptEditor,
+  testScript,
+  reactive({
+    extendedEditorConfig: {
+      mode: "application/javascript",
+      lineWrapping: linewrapEnabled,
+      placeholder: t("test.javascript_code").toString(),
+    },
+    linter,
+    completer,
+  })
+)
+
 const useSnippet = (script: string) => {
   testScript.value += script
 }

+ 2 - 2
packages/hoppscotch-app/components/lenses/HeadersRenderer.vue

@@ -47,7 +47,7 @@
           group-hover:text-secondaryDark
         "
       >
-        <span class="rounded select-all truncate">
+        <span class="rounded-sm select-all truncate">
           {{ header.key }}
         </span>
       </span>
@@ -61,7 +61,7 @@
           group-hover:text-secondaryDark
         "
       >
-        <span class="rounded select-all truncate">
+        <span class="rounded-sm select-all truncate">
           {{ header.value }}
         </span>
       </span>

+ 119 - 90
packages/hoppscotch-app/components/lenses/renderers/HTMLLensRenderer.vue

@@ -17,6 +17,14 @@
         {{ $t("response.body") }}
       </label>
       <div class="flex">
+        <ButtonSecondary
+          v-if="response.body"
+          v-tippy="{ theme: 'tooltip' }"
+          :title="$t('state.linewrap')"
+          :class="{ '!text-accent': linewrapEnabled }"
+          svg="corner-down-left"
+          @click.native.prevent="linewrapEnabled = !linewrapEnabled"
+        />
         <ButtonSecondary
           v-if="response.body"
           v-tippy="{ theme: 'tooltip' }"
@@ -44,110 +52,131 @@
         />
       </div>
     </div>
-    <div class="relative">
-      <SmartAceEditor
-        :value="responseBodyText"
-        :lang="'html'"
-        :options="{
-          maxLines: Infinity,
-          minLines: 16,
-          autoScrollEditorIntoView: true,
-          readOnly: true,
-          showPrintMargin: false,
-          useWorker: false,
-        }"
-        styles="border-b border-dividerLight"
-      />
-      <iframe
-        ref="previewFrame"
-        :class="{ hidden: !previewEnabled }"
-        class="covers-response"
-        src="about:blank"
-      ></iframe>
-    </div>
+    <div v-show="!previewEnabled" ref="htmlResponse"></div>
+    <iframe
+      v-show="previewEnabled"
+      ref="previewFrame"
+      class="covers-response"
+      src="about:blank"
+    ></iframe>
   </div>
 </template>
 
-<script>
-import { defineComponent } from "@nuxtjs/composition-api"
-import TextContentRendererMixin from "./mixins/TextContentRendererMixin"
+<script setup lang="ts">
+import { computed, ref, useContext, reactive } from "@nuxtjs/composition-api"
+import { useCodemirror } from "~/helpers/editor/codemirror"
 import { copyToClipboard } from "~/helpers/utils/clipboard"
+import "codemirror/mode/xml/xml"
+import "codemirror/mode/javascript/javascript"
+import "codemirror/mode/css/css"
+import "codemirror/mode/htmlmixed/htmlmixed"
+import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
 
-export default defineComponent({
-  mixins: [TextContentRendererMixin],
-  props: {
-    response: { type: Object, default: () => {} },
-  },
-  data() {
-    return {
-      downloadIcon: "download",
-      copyIcon: "copy",
-      previewEnabled: false,
-    }
-  },
-  methods: {
-    downloadResponse() {
-      const dataToWrite = this.responseBodyText
-      const file = new Blob([dataToWrite], { type: "text/html" })
-      const a = document.createElement("a")
-      const url = URL.createObjectURL(file)
-      a.href = url
-      // TODO get uri from meta
-      a.download = `${url.split("/").pop().split("#")[0].split("?")[0]}`
-      document.body.appendChild(a)
-      a.click()
-      this.downloadIcon = "check"
-      this.$toast.success(this.$t("state.download_started"), {
-        icon: "downloading",
-      })
-      setTimeout(() => {
-        document.body.removeChild(a)
-        URL.revokeObjectURL(url)
-        this.downloadIcon = "download"
-      }, 1000)
-    },
-    copyResponse() {
-      copyToClipboard(this.responseBodyText)
-      this.copyIcon = "check"
-      this.$toast.success(this.$t("state.copied_to_clipboard"), {
-        icon: "content_paste",
-      })
-      setTimeout(() => (this.copyIcon = "copy"), 1000)
-    },
-    togglePreview() {
-      this.previewEnabled = !this.previewEnabled
-      if (this.previewEnabled) {
-        if (
-          this.$refs.previewFrame.getAttribute("data-previewing-url") ===
-          this.url
-        )
-          return
-        // Use DOMParser to parse document HTML.
-        const previewDocument = new DOMParser().parseFromString(
-          this.responseBodyText,
-          "text/html"
-        )
-        // Inject <base href="..."> tag to head, to fix relative CSS/HTML paths.
-        previewDocument.head.innerHTML =
-          `<base href="${this.url}">` + previewDocument.head.innerHTML
-        // Finally, set the iframe source to the resulting HTML.
-        this.$refs.previewFrame.srcdoc =
-          previewDocument.documentElement.outerHTML
-        this.$refs.previewFrame.setAttribute("data-previewing-url", this.url)
-      }
-    },
-  },
+const props = defineProps<{
+  response: HoppRESTResponse
+}>()
+
+const {
+  $toast,
+  app: { i18n },
+} = useContext()
+const t = i18n.t.bind(i18n)
+
+const responseBodyText = computed(() => {
+  if (
+    props.response.type === "loading" ||
+    props.response.type === "network_fail"
+  )
+    return ""
+  if (typeof props.response.body === "string") return props.response.body
+  else {
+    const res = new TextDecoder("utf-8").decode(props.response.body)
+    // HACK: Temporary trailing null character issue from the extension fix
+    return res.replace(/\0+$/, "")
+  }
 })
+
+const downloadIcon = ref("download")
+const copyIcon = ref("copy")
+const previewEnabled = ref(false)
+const previewFrame = ref<any | null>(null)
+const url = ref("")
+
+const htmlResponse = ref<any | null>(null)
+const linewrapEnabled = ref(true)
+
+useCodemirror(
+  htmlResponse,
+  responseBodyText,
+  reactive({
+    extendedEditorConfig: {
+      mode: "htmlmixed",
+      readOnly: true,
+      lineWrapping: linewrapEnabled,
+    },
+    linter: null,
+    completer: null,
+  })
+)
+
+const downloadResponse = () => {
+  const dataToWrite = responseBodyText.value
+  const file = new Blob([dataToWrite], { type: "text/html" })
+  const a = document.createElement("a")
+  const url = URL.createObjectURL(file)
+  a.href = url
+  // TODO get uri from meta
+  a.download = `${url.split("/").pop().split("#")[0].split("?")[0]}`
+  document.body.appendChild(a)
+  a.click()
+  downloadIcon.value = "check"
+  $toast.success(t("state.download_started").toString(), {
+    icon: "downloading",
+  })
+  setTimeout(() => {
+    document.body.removeChild(a)
+    URL.revokeObjectURL(url)
+    downloadIcon.value = "download"
+  }, 1000)
+}
+
+const copyResponse = () => {
+  copyToClipboard(responseBodyText.value)
+  copyIcon.value = "check"
+  $toast.success(t("state.copied_to_clipboard").toString(), {
+    icon: "content_paste",
+  })
+  setTimeout(() => (copyIcon.value = "copy"), 1000)
+}
+
+const togglePreview = () => {
+  previewEnabled.value = !previewEnabled.value
+  if (previewEnabled.value) {
+    if (previewFrame.value.getAttribute("data-previewing-url") === url.value)
+      return
+    // Use DOMParser to parse document HTML.
+    const previewDocument = new DOMParser().parseFromString(
+      responseBodyText.value,
+      "text/html"
+    )
+    // Inject <base href="..."> tag to head, to fix relative CSS/HTML paths.
+    previewDocument.head.innerHTML =
+      `<base href="${url.value}">` + previewDocument.head.innerHTML
+    // Finally, set the iframe source to the resulting HTML.
+    previewFrame.value.srcdoc = previewDocument.documentElement.outerHTML
+    previewFrame.value.setAttribute("data-previewing-url", url.value)
+  }
+}
 </script>
 
 <style lang="scss" scoped>
 .covers-response {
-  @apply absolute;
-  @apply inset-0;
   @apply bg-white;
+  @apply min-h-64;
   @apply h-full;
   @apply w-full;
   @apply border;
   @apply border-dividerLight;
+  @apply z-5;
 }
 </style>

+ 4 - 6
packages/hoppscotch-app/components/lenses/renderers/ImageLensRenderer.vue

@@ -27,12 +27,10 @@
         />
       </div>
     </div>
-    <div class="flex relative">
-      <img
-        class="border-b border-dividerLight flex max-w-full flex-1"
-        :src="imageSource"
-      />
-    </div>
+    <img
+      class="border-b border-dividerLight flex max-w-full flex-1"
+      :src="imageSource"
+    />
   </div>
 </template>
 

+ 236 - 80
packages/hoppscotch-app/components/lenses/renderers/JSONLensRenderer.vue

@@ -13,10 +13,18 @@
         justify-between
       "
     >
-      <label class="font-semibold text-secondaryLight">
-        {{ $t("response.body") }}
-      </label>
+      <label class="font-semibold text-secondaryLight">{{
+        $t("response.body")
+      }}</label>
       <div class="flex">
+        <ButtonSecondary
+          v-if="response.body"
+          v-tippy="{ theme: 'tooltip' }"
+          :title="$t('state.linewrap')"
+          :class="{ '!text-accent': linewrapEnabled }"
+          svg="corner-down-left"
+          @click.native.prevent="linewrapEnabled = !linewrapEnabled"
+        />
         <ButtonSecondary
           v-if="response.body"
           ref="downloadResponse"
@@ -35,89 +43,237 @@
         />
       </div>
     </div>
-    <div class="relative">
-      <SmartAceEditor
-        :value="jsonBodyText"
-        :lang="'json'"
-        :provide-outline="true"
-        :options="{
-          maxLines: Infinity,
-          minLines: 16,
-          autoScrollEditorIntoView: true,
-          readOnly: true,
-          showPrintMargin: false,
-          useWorker: false,
-        }"
-        styles="border-b border-dividerLight"
-      />
+    <div ref="jsonResponse"></div>
+    <div
+      v-if="outlinePath"
+      class="
+        bg-primaryLight
+        border-t border-dividerLight
+        flex flex-nowrap flex-1
+        px-2
+        bottom-0
+        z-10
+        sticky
+        overflow-auto
+        hide-scrollbar
+      "
+    >
+      <div
+        v-for="(item, index) in outlinePath"
+        :key="`item-${index}`"
+        class="flex items-center"
+      >
+        <tippy
+          ref="outlineOptions"
+          interactive
+          trigger="click"
+          theme="popover"
+          arrow
+        >
+          <template #trigger>
+            <div v-if="item.kind === 'RootObject'" class="outline">{}</div>
+            <div v-if="item.kind === 'RootArray'" class="outline">[]</div>
+            <div v-if="item.kind === 'ArrayMember'" class="outline">
+              {{ item.index.toString() }}
+            </div>
+            <div v-if="item.kind === 'ObjectMember'" class="outline">
+              {{ item.name }}
+            </div>
+          </template>
+          <div
+            v-if="item.kind === 'ArrayMember' || item.kind === 'ObjectMember'"
+          >
+            <div v-if="item.kind === 'ArrayMember'" class="flex flex-col">
+              <SmartItem
+                v-for="(arrayMember, astIndex) in item.astParent.values"
+                :key="`ast-${astIndex}`"
+                :label="astIndex.toString()"
+                @click.native="
+                  () => {
+                    jumpCursor(arrayMember)
+                    outlineOptions[index].tippy().hide()
+                  }
+                "
+              />
+            </div>
+            <div v-if="item.kind === 'ObjectMember'" class="flex flex-col">
+              <SmartItem
+                v-for="(objectMember, astIndex) in item.astParent.members"
+                :key="`ast-${astIndex}`"
+                :label="objectMember.key.value"
+                @click.native="
+                  () => {
+                    jumpCursor(objectMember)
+                    outlineOptions[index].tippy().hide()
+                  }
+                "
+              />
+            </div>
+          </div>
+          <div v-if="item.kind === 'RootObject'" class="flex flex-col">
+            <SmartItem
+              label="{}"
+              @click.native="
+                () => {
+                  jumpCursor(item.astValue)
+                  outlineOptions[index].tippy().hide()
+                }
+              "
+            />
+          </div>
+          <div v-if="item.kind === 'RootArray'" class="flex flex-col">
+            <SmartItem
+              label="[]"
+              @click.native="
+                () => {
+                  jumpCursor(item.astValue)
+                  outlineOptions[index].tippy().hide()
+                }
+              "
+            />
+          </div>
+        </tippy>
+        <i
+          v-if="index + 1 !== outlinePath.length"
+          class="text-secondaryLight opacity-50 material-icons"
+          >chevron_right</i
+        >
+      </div>
     </div>
   </div>
 </template>
 
-<script>
-import { defineComponent } from "@nuxtjs/composition-api"
-import TextContentRendererMixin from "./mixins/TextContentRendererMixin"
+<script setup lang="ts">
+import { computed, ref, useContext, reactive } from "@nuxtjs/composition-api"
+import { useCodemirror } from "~/helpers/editor/codemirror"
 import { copyToClipboard } from "~/helpers/utils/clipboard"
+import "codemirror/mode/javascript/javascript"
+import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
+import jsonParse, { JSONObjectMember, JSONValue } from "~/helpers/jsonParse"
+import { getJSONOutlineAtPos } from "~/helpers/newOutline"
+import {
+  convertIndexToLineCh,
+  convertLineChToIndex,
+} from "~/helpers/editor/utils"
 
-export default defineComponent({
-  mixins: [TextContentRendererMixin],
-  props: {
-    response: { type: Object, default: () => {} },
-  },
-  data() {
-    return {
-      downloadIcon: "download",
-      copyIcon: "copy",
-    }
-  },
-  computed: {
-    jsonBodyText() {
-      try {
-        return JSON.stringify(JSON.parse(this.responseBodyText), null, 2)
-      } catch (e) {
-        // Most probs invalid JSON was returned, so drop prettification (should we warn ?)
-        return this.responseBodyText
-      }
-    },
-    responseType() {
-      return (
-        this.response.headers.find(
-          (h) => h.key.toLowerCase() === "content-type"
-        ).value || ""
-      )
-        .split(";")[0]
-        .toLowerCase()
-    },
-  },
-  methods: {
-    downloadResponse() {
-      const dataToWrite = this.responseBodyText
-      const file = new Blob([dataToWrite], { type: "application/json" })
-      const a = document.createElement("a")
-      const url = URL.createObjectURL(file)
-      a.href = url
-      // TODO get uri from meta
-      a.download = `${url.split("/").pop().split("#")[0].split("?")[0]}`
-      document.body.appendChild(a)
-      a.click()
-      this.downloadIcon = "check"
-      this.$toast.success(this.$t("state.download_started"), {
-        icon: "downloading",
-      })
-      setTimeout(() => {
-        document.body.removeChild(a)
-        URL.revokeObjectURL(url)
-        this.downloadIcon = "download"
-      }, 1000)
-    },
-    copyResponse() {
-      copyToClipboard(this.responseBodyText)
-      this.copyIcon = "check"
-      this.$toast.success(this.$t("state.copied_to_clipboard"), {
-        icon: "content_paste",
-      })
-      setTimeout(() => (this.copyIcon = "copy"), 1000)
+const props = defineProps<{
+  response: HoppRESTResponse
+}>()
+
+const {
+  $toast,
+  app: { i18n },
+} = useContext()
+const t = i18n.t.bind(i18n)
+
+const responseBodyText = computed(() => {
+  if (
+    props.response.type === "loading" ||
+    props.response.type === "network_fail"
+  )
+    return ""
+  if (typeof props.response.body === "string") return props.response.body
+  else {
+    const res = new TextDecoder("utf-8").decode(props.response.body)
+    // HACK: Temporary trailing null character issue from the extension fix
+    return res.replace(/\0+$/, "")
+  }
+})
+
+const downloadIcon = ref("download")
+const copyIcon = ref("copy")
+
+const jsonBodyText = computed(() => {
+  try {
+    return JSON.stringify(JSON.parse(responseBodyText.value), null, 2)
+  } catch (e) {
+    // Most probs invalid JSON was returned, so drop prettification (should we warn ?)
+    return responseBodyText.value
+  }
+})
+
+const ast = computed(() => {
+  try {
+    return jsonParse(jsonBodyText.value)
+  } catch (_: any) {
+    return null
+  }
+})
+
+const outlineOptions = ref<any | null>(null)
+const jsonResponse = ref<any | null>(null)
+const linewrapEnabled = ref(true)
+
+const { cursor } = useCodemirror(
+  jsonResponse,
+  jsonBodyText,
+  reactive({
+    extendedEditorConfig: {
+      mode: "application/ld+json",
+      readOnly: true,
+      lineWrapping: linewrapEnabled,
     },
-  },
+    linter: null,
+    completer: null,
+  })
+)
+
+const jumpCursor = (ast: JSONValue | JSONObjectMember) => {
+  const pos = convertIndexToLineCh(jsonBodyText.value, ast.start)
+  pos.line--
+  cursor.value = pos
+}
+
+const downloadResponse = () => {
+  const dataToWrite = responseBodyText.value
+  const file = new Blob([dataToWrite], { type: "application/json" })
+  const a = document.createElement("a")
+  const url = URL.createObjectURL(file)
+  a.href = url
+  // TODO get uri from meta
+  a.download = `${url.split("/").pop().split("#")[0].split("?")[0]}`
+  document.body.appendChild(a)
+  a.click()
+  downloadIcon.value = "check"
+  $toast.success(t("state.download_started").toString(), {
+    icon: "downloading",
+  })
+  setTimeout(() => {
+    document.body.removeChild(a)
+    URL.revokeObjectURL(url)
+    downloadIcon.value = "download"
+  }, 1000)
+}
+
+const outlinePath = computed(() => {
+  if (ast.value) {
+    return getJSONOutlineAtPos(
+      ast.value,
+      convertLineChToIndex(jsonBodyText.value, cursor.value)
+    )
+  } else return null
 })
+
+const copyResponse = () => {
+  copyToClipboard(responseBodyText.value)
+  copyIcon.value = "check"
+  $toast.success(t("state.copied_to_clipboard").toString(), {
+    icon: "content_paste",
+  })
+  setTimeout(() => (copyIcon.value = "copy"), 1000)
+}
 </script>
+
+<style lang="scss" scoped>
+.outline {
+  @apply cursor-pointer;
+  @apply flex-grow-0 flex-shrink-0;
+  @apply text-secondaryLight;
+  @apply inline-flex;
+  @apply items-center;
+  @apply px-2;
+  @apply py-1;
+  @apply transition;
+  @apply hover:text-secondary;
+}
+</style>

+ 94 - 70
packages/hoppscotch-app/components/lenses/renderers/RawLensRenderer.vue

@@ -17,6 +17,14 @@
         {{ $t("response.body") }}
       </label>
       <div class="flex">
+        <ButtonSecondary
+          v-if="response.body"
+          v-tippy="{ theme: 'tooltip' }"
+          :title="$t('state.linewrap')"
+          :class="{ '!text-accent': linewrapEnabled }"
+          svg="corner-down-left"
+          @click.native.prevent="linewrapEnabled = !linewrapEnabled"
+        />
         <ButtonSecondary
           v-if="response.body"
           ref="downloadResponse"
@@ -35,80 +43,96 @@
         />
       </div>
     </div>
-    <div class="relative">
-      <SmartAceEditor
-        :value="responseBodyText"
-        :lang="'plain_text'"
-        :options="{
-          maxLines: Infinity,
-          minLines: 16,
-          autoScrollEditorIntoView: true,
-          readOnly: true,
-          showPrintMargin: false,
-          useWorker: false,
-        }"
-        styles="border-b border-dividerLight"
-      />
-    </div>
+    <div ref="rawResponse"></div>
   </div>
 </template>
 
-<script>
-import { defineComponent } from "@nuxtjs/composition-api"
-import TextContentRendererMixin from "./mixins/TextContentRendererMixin"
+<script setup lang="ts">
+import { ref, useContext, computed, reactive } from "@nuxtjs/composition-api"
+import { useCodemirror } from "~/helpers/editor/codemirror"
 import { copyToClipboard } from "~/helpers/utils/clipboard"
+import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
 
-export default defineComponent({
-  mixins: [TextContentRendererMixin],
-  props: {
-    response: { type: Object, default: () => {} },
-  },
-  data() {
-    return {
-      downloadIcon: "download",
-      copyIcon: "copy",
-    }
-  },
-  computed: {
-    responseType() {
-      return (
-        this.response.headers.find(
-          (h) => h.key.toLowerCase() === "content-type"
-        ).value || ""
-      )
-        .split(";")[0]
-        .toLowerCase()
-    },
-  },
-  methods: {
-    downloadResponse() {
-      const dataToWrite = this.responseBodyText
-      const file = new Blob([dataToWrite], { type: this.responseType })
-      const a = document.createElement("a")
-      const url = URL.createObjectURL(file)
-      a.href = url
-      // TODO get uri from meta
-      a.download = `${url.split("/").pop().split("#")[0].split("?")[0]}`
-      document.body.appendChild(a)
-      a.click()
-      this.downloadIcon = "check"
-      this.$toast.success(this.$t("state.download_started"), {
-        icon: "downloading",
-      })
-      setTimeout(() => {
-        document.body.removeChild(a)
-        URL.revokeObjectURL(url)
-        this.downloadIcon = "download"
-      }, 1000)
-    },
-    copyResponse() {
-      copyToClipboard(this.responseBodyText)
-      this.copyIcon = "check"
-      this.$toast.success(this.$t("state.copied_to_clipboard"), {
-        icon: "content_paste",
-      })
-      setTimeout(() => (this.copyIcon = "copy"), 1000)
-    },
-  },
+const props = defineProps<{
+  response: HoppRESTResponse
+}>()
+
+const {
+  $toast,
+  app: { i18n },
+} = useContext()
+const t = i18n.t.bind(i18n)
+
+const responseBodyText = computed(() => {
+  if (
+    props.response.type === "loading" ||
+    props.response.type === "network_fail"
+  )
+    return ""
+  if (typeof props.response.body === "string") return props.response.body
+  else {
+    const res = new TextDecoder("utf-8").decode(props.response.body)
+    // HACK: Temporary trailing null character issue from the extension fix
+    return res.replace(/\0+$/, "")
+  }
 })
+
+const downloadIcon = ref("download")
+const copyIcon = ref("copy")
+
+const responseType = computed(() => {
+  return (
+    props.response.headers.find((h) => h.key.toLowerCase() === "content-type")
+      .value || ""
+  )
+    .split(";")[0]
+    .toLowerCase()
+})
+
+const rawResponse = ref<any | null>(null)
+const linewrapEnabled = ref(true)
+
+useCodemirror(
+  rawResponse,
+  responseBodyText,
+  reactive({
+    extendedEditorConfig: {
+      mode: "text/plain",
+      readOnly: true,
+      lineWrapping: linewrapEnabled,
+    },
+    linter: null,
+    completer: null,
+  })
+)
+
+const downloadResponse = () => {
+  const dataToWrite = responseBodyText.value
+  const file = new Blob([dataToWrite], { type: responseType.value })
+  const a = document.createElement("a")
+  const url = URL.createObjectURL(file)
+  a.href = url
+  // TODO get uri from meta
+  a.download = `${url.split("/").pop().split("#")[0].split("?")[0]}`
+  document.body.appendChild(a)
+  a.click()
+  downloadIcon.value = "check"
+  $toast.success(t("state.download_started").toString(), {
+    icon: "downloading",
+  })
+  setTimeout(() => {
+    document.body.removeChild(a)
+    URL.revokeObjectURL(url)
+    downloadIcon.value = "download"
+  }, 1000)
+}
+
+const copyResponse = () => {
+  copyToClipboard(responseBodyText.value)
+  copyIcon.value = "check"
+  $toast.success(t("state.copied_to_clipboard").toString(), {
+    icon: "content_paste",
+  })
+  setTimeout(() => (copyIcon.value = "copy"), 1000)
+}
 </script>

+ 95 - 70
packages/hoppscotch-app/components/lenses/renderers/XMLLensRenderer.vue

@@ -17,6 +17,14 @@
         {{ $t("response.body") }}
       </label>
       <div class="flex">
+        <ButtonSecondary
+          v-if="response.body"
+          v-tippy="{ theme: 'tooltip' }"
+          :title="$t('state.linewrap')"
+          :class="{ '!text-accent': linewrapEnabled }"
+          svg="corner-down-left"
+          @click.native.prevent="linewrapEnabled = !linewrapEnabled"
+        />
         <ButtonSecondary
           v-if="response.body"
           ref="downloadResponse"
@@ -35,80 +43,97 @@
         />
       </div>
     </div>
-    <div class="relative">
-      <SmartAceEditor
-        :value="responseBodyText"
-        :lang="'xml'"
-        :options="{
-          maxLines: Infinity,
-          minLines: 16,
-          autoScrollEditorIntoView: true,
-          readOnly: true,
-          showPrintMargin: false,
-          useWorker: false,
-        }"
-        styles="border-b border-dividerLight"
-      />
-    </div>
+    <div ref="xmlResponse"></div>
   </div>
 </template>
 
-<script>
-import { defineComponent } from "@nuxtjs/composition-api"
-import TextContentRendererMixin from "./mixins/TextContentRendererMixin"
+<script setup lang="ts">
+import { computed, ref, useContext, reactive } from "@nuxtjs/composition-api"
+import { useCodemirror } from "~/helpers/editor/codemirror"
 import { copyToClipboard } from "~/helpers/utils/clipboard"
+import "codemirror/mode/xml/xml"
+import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
 
-export default defineComponent({
-  mixins: [TextContentRendererMixin],
-  props: {
-    response: { type: Object, default: () => {} },
-  },
-  data() {
-    return {
-      copyIcon: "copy",
-      downloadIcon: "download",
-    }
-  },
-  computed: {
-    responseType() {
-      return (
-        this.response.headers.find(
-          (h) => h.key.toLowerCase() === "content-type"
-        ).value || ""
-      )
-        .split(";")[0]
-        .toLowerCase()
-    },
-  },
-  methods: {
-    downloadResponse() {
-      const dataToWrite = this.responseBodyText
-      const file = new Blob([dataToWrite], { type: this.responseType })
-      const a = document.createElement("a")
-      const url = URL.createObjectURL(file)
-      a.href = url
-      // TODO get uri from meta
-      a.download = `${url.split("/").pop().split("#")[0].split("?")[0]}`
-      document.body.appendChild(a)
-      a.click()
-      this.downloadIcon = "check"
-      this.$toast.success(this.$t("state.download_started"), {
-        icon: "downloading",
-      })
-      setTimeout(() => {
-        document.body.removeChild(a)
-        URL.revokeObjectURL(url)
-        this.downloadIcon = "download"
-      }, 1000)
-    },
-    copyResponse() {
-      copyToClipboard(this.responseBodyText)
-      this.copyIcon = "check"
-      this.$toast.success(this.$t("state.copied_to_clipboard"), {
-        icon: "content_paste",
-      })
-      setTimeout(() => (this.copyIcon = "copy"), 1000)
-    },
-  },
+const props = defineProps<{
+  response: HoppRESTResponse
+}>()
+
+const {
+  $toast,
+  app: { i18n },
+} = useContext()
+const t = i18n.t.bind(i18n)
+
+const responseBodyText = computed(() => {
+  if (
+    props.response.type === "loading" ||
+    props.response.type === "network_fail"
+  )
+    return ""
+  if (typeof props.response.body === "string") return props.response.body
+  else {
+    const res = new TextDecoder("utf-8").decode(props.response.body)
+    // HACK: Temporary trailing null character issue from the extension fix
+    return res.replace(/\0+$/, "")
+  }
 })
+
+const downloadIcon = ref("download")
+const copyIcon = ref("copy")
+
+const responseType = computed(() => {
+  return (
+    props.response.headers.find((h) => h.key.toLowerCase() === "content-type")
+      .value || ""
+  )
+    .split(";")[0]
+    .toLowerCase()
+})
+
+const xmlResponse = ref<any | null>(null)
+const linewrapEnabled = ref(true)
+
+useCodemirror(
+  xmlResponse,
+  responseBodyText,
+  reactive({
+    extendedEditorConfig: {
+      mode: "application/xml",
+      readOnly: true,
+      lineWrapping: linewrapEnabled,
+    },
+    linter: null,
+    completer: null,
+  })
+)
+
+const downloadResponse = () => {
+  const dataToWrite = responseBodyText.value
+  const file = new Blob([dataToWrite], { type: responseType.value })
+  const a = document.createElement("a")
+  const url = URL.createObjectURL(file)
+  a.href = url
+  // TODO get uri from meta
+  a.download = `${url.split("/").pop().split("#")[0].split("?")[0]}`
+  document.body.appendChild(a)
+  a.click()
+  downloadIcon.value = "check"
+  $toast.success(t("state.download_started").toString(), {
+    icon: "downloading",
+  })
+  setTimeout(() => {
+    document.body.removeChild(a)
+    URL.revokeObjectURL(url)
+    downloadIcon.value = "download"
+  }, 1000)
+}
+
+const copyResponse = () => {
+  copyToClipboard(responseBodyText.value)
+  copyIcon.value = "check"
+  $toast.success(t("state.copied_to_clipboard").toString(), {
+    icon: "content_paste",
+  })
+  setTimeout(() => (copyIcon.value = "copy"), 1000)
+}
 </script>

+ 9 - 23
packages/hoppscotch-app/components/realtime/Log.vue

@@ -17,14 +17,13 @@
         {{ title }}
       </label>
     </div>
-    <div ref="log" name="log" class="realtime-log">
+    <div name="log" class="realtime-log">
       <span v-if="log" class="space-y-2">
         <span
           v-for="(entry, index) in log"
           :key="`entry-${index}`"
           :style="{ color: entry.color }"
-          >{{ entry.ts }}{{ getSourcePrefix(entry.source)
-          }}{{ entry.payload }}</span
+          >{{ entry.ts }}{{ source(entry.source) }}{{ entry.payload }}</span
         >
       </span>
       <span v-else>{{ $t("response.waiting_for_connection") }}</span>
@@ -32,27 +31,14 @@
   </div>
 </template>
 
-<script>
-import { defineComponent } from "@nuxtjs/composition-api"
-import { getSourcePrefix } from "~/helpers/utils/string"
+<script setup lang="ts">
+import { getSourcePrefix as source } from "~/helpers/utils/string"
 
-export default defineComponent({
-  props: {
-    log: { type: Array, default: () => [] },
-    title: {
-      type: String,
-      default: "",
-    },
-  },
-  updated() {
-    this.$nextTick(function () {
-      if (this.$refs.log) {
-        this.$refs.log.scrollBy(0, this.$refs.log.scrollHeight + 100)
-      }
-    })
-  },
-  methods: {
-    getSourcePrefix,
+defineProps({
+  log: { type: Array, default: () => [] },
+  title: {
+    type: String,
+    default: "",
   },
 })
 </script>

+ 1 - 1
packages/hoppscotch-app/components/realtime/Mqtt.vue

@@ -145,7 +145,7 @@ import { defineComponent } from "@nuxtjs/composition-api"
 import { Splitpanes, Pane } from "splitpanes"
 import "splitpanes/dist/splitpanes.css"
 import Paho from "paho-mqtt"
-import debounce from "~/helpers/utils/debounce"
+import debounce from "lodash/debounce"
 import { logHoppRequestRunToAnalytics } from "~/helpers/fb/analytics"
 import { useSetting } from "~/newstore/settings"
 import useWindowSize from "~/helpers/utils/useWindowSize"

+ 1 - 1
packages/hoppscotch-app/components/realtime/Socketio.vue

@@ -165,7 +165,7 @@ import { Splitpanes, Pane } from "splitpanes"
 import "splitpanes/dist/splitpanes.css"
 import { io as Client } from "socket.io-client"
 import wildcard from "socketio-wildcard"
-import debounce from "~/helpers/utils/debounce"
+import debounce from "lodash/debounce"
 import { logHoppRequestRunToAnalytics } from "~/helpers/fb/analytics"
 import { useSetting } from "~/newstore/settings"
 import useWindowSize from "~/helpers/utils/useWindowSize"

+ 1 - 1
packages/hoppscotch-app/components/realtime/Sse.vue

@@ -89,8 +89,8 @@
 import { defineComponent } from "@nuxtjs/composition-api"
 import { Splitpanes, Pane } from "splitpanes"
 import "splitpanes/dist/splitpanes.css"
+import debounce from "lodash/debounce"
 import { logHoppRequestRunToAnalytics } from "~/helpers/fb/analytics"
-import debounce from "~/helpers/utils/debounce"
 
 export default defineComponent({
   components: { Splitpanes, Pane },

+ 1 - 1
packages/hoppscotch-app/components/realtime/Websocket.vue

@@ -205,8 +205,8 @@
 import { defineComponent } from "@nuxtjs/composition-api"
 import { Splitpanes, Pane } from "splitpanes"
 import "splitpanes/dist/splitpanes.css"
+import debounce from "lodash/debounce"
 import { logHoppRequestRunToAnalytics } from "~/helpers/fb/analytics"
-import debounce from "~/helpers/utils/debounce"
 import useWindowSize from "~/helpers/utils/useWindowSize"
 import { useSetting } from "~/newstore/settings"
 

+ 0 - 282
packages/hoppscotch-app/components/smart/AceEditor.vue

@@ -1,282 +0,0 @@
-<template>
-  <div class="show-if-initialized" :class="{ initialized }">
-    <pre ref="editor" :class="styles"></pre>
-    <div
-      v-if="provideOutline"
-      class="
-        bg-primaryLight
-        border-t border-divider
-        flex flex-nowrap flex-1
-        py-1
-        px-4
-        bottom-0
-        z-10
-        sticky
-        overflow-auto
-        hide-scrollbar
-      "
-    >
-      <div
-        v-for="(p, index) in currentPath"
-        :key="`p-${index}`"
-        class="
-          cursor-pointer
-          flex-grow-0 flex-shrink-0
-          text-secondaryLight
-          inline-flex
-          items-center
-          hover:text-secondary
-        "
-      >
-        <span @click="onBlockClick(index)">
-          {{ p }}
-        </span>
-        <i v-if="index + 1 !== currentPath.length" class="mx-2 material-icons">
-          chevron_right
-        </i>
-        <tippy
-          v-if="siblingDropDownIndex == index"
-          ref="options"
-          interactive
-          trigger="click"
-          theme="popover"
-          arrow
-        >
-          <SmartItem
-            v-for="(sibling, siblingIndex) in currentSibling"
-            :key="`p-${index}-sibling-${siblingIndex}`"
-            :label="sibling.key ? sibling.key.value : i"
-            @click.native="goToSibling(sibling)"
-          />
-        </tippy>
-      </div>
-    </div>
-  </div>
-</template>
-
-<script>
-import ace from "ace-builds"
-import "ace-builds/webpack-resolver"
-import { defineComponent } from "@nuxtjs/composition-api"
-import jsonParse from "~/helpers/jsonParse"
-import debounce from "~/helpers/utils/debounce"
-import outline from "~/helpers/outline"
-
-export default defineComponent({
-  props: {
-    provideOutline: {
-      type: Boolean,
-      default: false,
-      required: false,
-    },
-    value: {
-      type: String,
-      default: "",
-    },
-    theme: {
-      type: String,
-      required: false,
-      default: null,
-    },
-    lang: {
-      type: String,
-      default: "json",
-    },
-    lint: {
-      type: Boolean,
-      default: true,
-      required: false,
-    },
-    options: {
-      type: Object,
-      default: () => {},
-    },
-    styles: {
-      type: String,
-      default: "",
-    },
-  },
-
-  data() {
-    return {
-      initialized: false,
-      editor: null,
-      cacheValue: "",
-      outline: outline(),
-      currentPath: [],
-      currentSibling: [],
-      siblingDropDownIndex: null,
-    }
-  },
-
-  computed: {
-    appFontSize() {
-      return getComputedStyle(document.documentElement).getPropertyValue(
-        "--body-font-size"
-      )
-    },
-  },
-
-  watch: {
-    value(value) {
-      if (value !== this.cacheValue) {
-        this.editor.session.setValue(value, 1)
-        this.cacheValue = value
-        if (this.lint) this.provideLinting(value)
-      }
-    },
-    theme() {
-      this.initialized = false
-      this.editor.setTheme(`ace/theme/${this.defineTheme()}`, () => {
-        this.$nextTick().then(() => {
-          this.initialized = true
-        })
-      })
-    },
-    lang(value) {
-      this.editor.getSession().setMode(`ace/mode/${value}`)
-    },
-    options(value) {
-      this.editor.setOptions(value)
-    },
-  },
-
-  mounted() {
-    const editor = ace.edit(this.$refs.editor, {
-      mode: `ace/mode/${this.lang}`,
-      ...this.options,
-    })
-
-    // Set the theme and show the editor only after it's been set to prevent FOUC.
-    editor.setTheme(`ace/theme/${this.defineTheme()}`, () => {
-      this.$nextTick().then(() => {
-        this.initialized = true
-      })
-    })
-
-    editor.setFontSize(this.appFontSize)
-
-    if (this.value) editor.setValue(this.value, 1)
-
-    this.editor = editor
-    this.cacheValue = this.value
-
-    if (this.lang === "json" && this.provideOutline)
-      this.initOutline(this.value)
-
-    editor.on("change", () => {
-      const content = editor.getValue()
-      this.$emit("input", content)
-      this.cacheValue = content
-
-      if (this.provideOutline) debounce(this.initOutline(content), 500)
-
-      if (this.lint) this.provideLinting(content)
-    })
-
-    if (this.lang === "json" && this.provideOutline) {
-      editor.session.selection.on("changeCursor", () => {
-        const index = editor.session.doc.positionToIndex(
-          editor.selection.getCursor(),
-          0
-        )
-        const path = this.outline.genPath(index)
-        if (path.success) {
-          this.currentPath = path.res
-        }
-      })
-    }
-
-    // Disable linting, if lint prop is false
-    if (this.lint) this.provideLinting(this.value)
-  },
-
-  destroyed() {
-    this.editor.destroy()
-  },
-
-  methods: {
-    defineTheme() {
-      if (this.theme) {
-        return this.theme
-      }
-      const strip = (str) =>
-        str.replace(/#/g, "").replace(/ /g, "").replace(/"/g, "")
-      return strip(
-        window
-          .getComputedStyle(document.documentElement)
-          .getPropertyValue("--editor-theme")
-      )
-    },
-
-    provideLinting: debounce(function (code) {
-      if (this.lang === "json") {
-        try {
-          jsonParse(code)
-          this.editor.session.setAnnotations([])
-        } catch (e) {
-          const pos = this.editor.session
-            .getDocument()
-            .indexToPosition(e.start, 0)
-          this.editor.session.setAnnotations([
-            {
-              row: pos.row,
-              column: pos.column,
-              text: e.message,
-              type: "error",
-            },
-          ])
-        }
-      }
-    }, 2000),
-
-    onBlockClick(index) {
-      if (this.siblingDropDownIndex === index) {
-        this.clearSiblingList()
-      } else {
-        this.currentSibling = this.outline.getSiblings(index)
-        if (this.currentSibling.length) this.siblingDropDownIndex = index
-      }
-    },
-    clearSiblingList() {
-      this.currentSibling = []
-      this.siblingDropDownIndex = null
-    },
-    goToSibling(obj) {
-      this.clearSiblingList()
-      if (obj.start) {
-        const pos = this.editor.session.doc.indexToPosition(obj.start, 0)
-        if (pos) {
-          this.editor.session.selection.moveCursorTo(pos.row, pos.column, true)
-          this.editor.session.selection.clearSelection()
-          this.editor.scrollToLine(pos.row, false, true, null)
-        }
-      }
-    },
-    initOutline: debounce(function (content) {
-      if (this.lang === "json") {
-        try {
-          this.outline.init(content)
-
-          if (content[0] === "[") this.currentPath.push("[]")
-          else this.currentPath.push("{}")
-        } catch (e) {
-          console.log("Outline error: ", e)
-        }
-      }
-    }),
-  },
-})
-</script>
-
-<style scoped lang="scss">
-.show-if-initialized {
-  &.initialized {
-    @apply opacity-100;
-  }
-
-  & > * {
-    @apply transition-none;
-  }
-}
-</style>

+ 10 - 0
packages/hoppscotch-app/components/smart/AutoComplete.vue

@@ -150,6 +150,16 @@ export default defineComponent({
 
     handleKeystroke(event) {
       switch (event.code) {
+        case "Enter":
+          event.preventDefault()
+          if (this.currentSuggestionIndex > -1)
+            this.forceSuggestion(
+              this.suggestions.find(
+                (_item, index) => index === this.currentSuggestionIndex
+              )
+            )
+          break
+
         case "ArrowUp":
           event.preventDefault()
           this.currentSuggestionIndex =

+ 1 - 2
packages/hoppscotch-app/components/smart/EnvInput.vue

@@ -483,7 +483,7 @@ export default defineComponent({
     line-height: 1.9;
 
     &::before {
-      @apply text-secondaryDark;
+      @apply text-secondary;
       @apply opacity-25;
       @apply pointer-events-none;
 
@@ -501,7 +501,6 @@ export default defineComponent({
   @apply overflow-y-hidden;
   @apply resize-none;
   @apply focus:outline-none;
-  @apply transition;
 }
 
 .env-input::-webkit-scrollbar {

+ 0 - 292
packages/hoppscotch-app/components/smart/JsEditor.vue

@@ -1,292 +0,0 @@
-<template>
-  <div class="show-if-initialized" :class="{ initialized }">
-    <pre ref="editor" :class="styles"></pre>
-  </div>
-</template>
-
-<script>
-import ace from "ace-builds"
-import "ace-builds/webpack-resolver"
-import "ace-builds/src-noconflict/ext-language_tools"
-import "ace-builds/src-noconflict/mode-graphqlschema"
-import * as esprima from "esprima"
-import { defineComponent } from "@nuxtjs/composition-api"
-import debounce from "~/helpers/utils/debounce"
-import {
-  getPreRequestScriptCompletions,
-  getTestScriptCompletions,
-  performPreRequestLinting,
-  performTestLinting,
-} from "~/helpers/tern"
-
-export default defineComponent({
-  props: {
-    value: {
-      type: String,
-      default: "",
-    },
-    theme: {
-      type: String,
-      required: false,
-      default: null,
-    },
-    options: {
-      type: Object,
-      default: () => {},
-    },
-    styles: {
-      type: String,
-      default: "",
-    },
-    completeMode: {
-      type: String,
-      required: true,
-      default: "none",
-    },
-  },
-
-  data() {
-    return {
-      initialized: false,
-      editor: null,
-      cacheValue: "",
-    }
-  },
-
-  computed: {
-    appFontSize() {
-      return getComputedStyle(document.documentElement).getPropertyValue(
-        "--body-font-size"
-      )
-    },
-  },
-
-  watch: {
-    value(value) {
-      if (value !== this.cacheValue) {
-        this.editor.session.setValue(value, 1)
-        this.cacheValue = value
-        if (this.lint) this.provideLinting(value)
-      }
-    },
-    theme() {
-      this.initialized = false
-      this.editor.setTheme(`ace/theme/${this.defineTheme()}`, () => {
-        this.$nextTick()
-          .then(() => {
-            this.initialized = true
-          })
-          .catch(() => {
-            // nextTick shouldn't really ever throw but still
-            this.initialized = true
-          })
-      })
-    },
-    options(value) {
-      this.editor.setOptions(value)
-    },
-  },
-
-  mounted() {
-    // const langTools = ace.require("ace/ext/language_tools")
-
-    const editor = ace.edit(this.$refs.editor, {
-      mode: `ace/mode/javascript`,
-      enableBasicAutocompletion: true,
-      enableLiveAutocompletion: true,
-      ...this.options,
-    })
-
-    // Set the theme and show the editor only after it's been set to prevent FOUC.
-    editor.setTheme(`ace/theme/${this.defineTheme()}`, () => {
-      this.$nextTick()
-        .then(() => {
-          this.initialized = true
-        })
-        .catch(() => {
-          // nextTIck shouldn't really ever throw but still
-          this.initialized = true
-        })
-    })
-
-    editor.setFontSize(this.appFontSize)
-
-    const completer = {
-      getCompletions: (
-        editor,
-        _session,
-        { row, column },
-        _prefix,
-        callback
-      ) => {
-        if (this.completeMode === "pre") {
-          getPreRequestScriptCompletions(editor.getValue(), row, column)
-            .then((res) => {
-              callback(
-                null,
-                res.completions.map((r, index, arr) => ({
-                  name: r.name,
-                  value: r.name,
-                  score: (arr.length - index) / arr.length,
-                  meta: r.type,
-                }))
-              )
-            })
-            .catch(() => callback(null, []))
-        } else if (this.completeMode === "test") {
-          getTestScriptCompletions(editor.getValue(), row, column)
-            .then((res) => {
-              callback(
-                null,
-                res.completions.map((r, index, arr) => ({
-                  name: r.name,
-                  value: r.name,
-                  score: (arr.length - index) / arr.length,
-                  meta: r.type,
-                }))
-              )
-            })
-            .catch(() => callback(null, []))
-        }
-      },
-    }
-
-    editor.completers = [completer]
-
-    if (this.value) editor.setValue(this.value, 1)
-
-    this.editor = editor
-    this.cacheValue = this.value
-
-    editor.on("change", () => {
-      const content = editor.getValue()
-      this.$emit("input", content)
-      this.cacheValue = content
-      this.provideLinting(content)
-    })
-
-    this.provideLinting(this.value)
-  },
-
-  destroyed() {
-    this.editor.destroy()
-  },
-
-  methods: {
-    defineTheme() {
-      if (this.theme) {
-        return this.theme
-      }
-      const strip = (str) =>
-        str.replace(/#/g, "").replace(/ /g, "").replace(/"/g, "")
-      return strip(
-        window
-          .getComputedStyle(document.documentElement)
-          .getPropertyValue("--editor-theme")
-      )
-    },
-
-    provideLinting: debounce(function (code) {
-      let results = []
-
-      const lintFunc =
-        this.completeMode === "pre"
-          ? performPreRequestLinting
-          : performTestLinting
-
-      lintFunc(code)
-        .then((semanticLints) => {
-          results = results.concat(
-            semanticLints.map((lint) => ({
-              row: lint.from.line,
-              column: lint.from.ch,
-              text: `[semantic] ${lint.message}`,
-              type: "error",
-            }))
-          )
-
-          try {
-            const res = esprima.parseScript(code, { tolerant: true })
-            if (res.errors && res.errors.length > 0) {
-              results = results.concat(
-                res.errors.map((err) => {
-                  const pos = this.editor.session
-                    .getDocument()
-                    .indexToPosition(err.index, 0)
-
-                  return {
-                    row: pos.row,
-                    column: pos.column,
-                    text: `[syntax] ${err.description}`,
-                    type: "error",
-                  }
-                })
-              )
-            }
-          } catch (e) {
-            const pos = this.editor.session
-              .getDocument()
-              .indexToPosition(e.index, 0)
-            results = results.concat([
-              {
-                row: pos.row,
-                column: pos.column,
-                text: `[syntax] ${e.description}`,
-                type: "error",
-              },
-            ])
-          }
-
-          this.editor.session.setAnnotations(results)
-        })
-        .catch(() => {
-          try {
-            const res = esprima.parseScript(code, { tolerant: true })
-            if (res.errors && res.errors.length > 0) {
-              results = results.concat(
-                res.errors.map((err) => {
-                  const pos = this.editor.session
-                    .getDocument()
-                    .indexToPosition(err.index, 0)
-
-                  return {
-                    row: pos.row,
-                    column: pos.column,
-                    text: `[syntax] ${err.description}`,
-                    type: "error",
-                  }
-                })
-              )
-            }
-          } catch (e) {
-            const pos = this.editor.session
-              .getDocument()
-              .indexToPosition(e.index, 0)
-            results = results.concat([
-              {
-                row: pos.row,
-                column: pos.column,
-                text: `[syntax] ${e.description}`,
-                type: "error",
-              },
-            ])
-          }
-
-          this.editor.session.setAnnotations(results)
-        })
-    }, 2000),
-  },
-})
-</script>
-
-<style scoped lang="scss">
-.show-if-initialized {
-  &.initialized {
-    @apply opacity-100;
-  }
-
-  & > * {
-    @apply transition-none;
-  }
-}
-</style>

+ 1 - 1
packages/hoppscotch-app/helpers/GQLConnection.ts

@@ -195,7 +195,7 @@ export class GQLConnection {
       method: "post",
       url,
       headers: {
-        ...headers,
+        ...finalHeaders,
         "content-type": "application/json",
       },
       data: JSON.stringify({

+ 7 - 7
packages/hoppscotch-app/helpers/__tests__/editorutils.spec.js

@@ -15,16 +15,16 @@ describe("getEditorLangForMimeType", () => {
     expect(getEditorLangForMimeType("text/html")).toMatch("html")
   })
 
-  test("returns 'plain_text' for plain text mime", () => {
-    expect(getEditorLangForMimeType("text/plain")).toMatch("plain_text")
+  test("returns 'text/x-yaml' for plain text mime", () => {
+    expect(getEditorLangForMimeType("text/plain")).toMatch("text/x-yaml")
   })
 
-  test("returns 'plain_text' for unimplemented mimes", () => {
-    expect(getEditorLangForMimeType("image/gif")).toMatch("plain_text")
+  test("returns 'text/x-yaml' for unimplemented mimes", () => {
+    expect(getEditorLangForMimeType("image/gif")).toMatch("text/x-yaml")
   })
 
-  test("returns 'plain_text' for null/undefined mimes", () => {
-    expect(getEditorLangForMimeType(null)).toMatch("plain_text")
-    expect(getEditorLangForMimeType(undefined)).toMatch("plain_text")
+  test("returns 'text/x-yaml' for null/undefined mimes", () => {
+    expect(getEditorLangForMimeType(null)).toMatch("text/x-yaml")
+    expect(getEditorLangForMimeType(undefined)).toMatch("text/x-yaml")
   })
 })

+ 0 - 2
packages/hoppscotch-app/helpers/codegen/codegen.ts

@@ -150,8 +150,6 @@ function getCodegenGeneralRESTInfo(
           .map((x) => ({ ...x, active: true }))
       : request.effectiveFinalHeaders.map((x) => ({ ...x, active: true }))
 
-  console.log(finalHeaders)
-
   return {
     name: request.name,
     uri: request.effectiveFinalURL,

+ 1 - 1
packages/hoppscotch-app/helpers/codegen/generators/c-libcurl.js

@@ -24,7 +24,7 @@ export const CLibcurlCodegen = {
       `curl_easy_setopt(hnd, CURLOPT_CUSTOMREQUEST, "${method}");`
     )
     requestString.push(
-      `curl_easy_setopt(hnd, CURLOPT_URL, "${url}${pathName}${queryString}");`
+      `curl_easy_setopt(hnd, CURLOPT_URL, "${url}${pathName}?${queryString}");`
     )
     requestString.push(`struct curl_slist *headers = NULL;`)
 

+ 1 - 1
packages/hoppscotch-app/helpers/codegen/generators/cs-restsharp.js

@@ -48,7 +48,7 @@ export const CsRestsharpCodegen = {
     // create client and request
     requestString.push(`var client = new RestClient("${url}");\n\n`)
     requestString.push(
-      `var request = new RestRequest("${pathName}${queryString}", ${requestDataFormat});\n\n`
+      `var request = new RestRequest("${pathName}?${queryString}", ${requestDataFormat});\n\n`
     )
 
     // authentification

+ 1 - 1
packages/hoppscotch-app/helpers/codegen/generators/curl.js

@@ -19,7 +19,7 @@ export const CurlCodegen = {
   }) => {
     const requestString = []
     requestString.push(`curl -X ${method}`)
-    requestString.push(`  '${url}${pathName}${queryString}'`)
+    requestString.push(`  '${url}${pathName}?${queryString}'`)
     if (auth === "Basic Auth") {
       const basic = `${httpUser}:${httpPassword}`
       requestString.push(

+ 3 - 3
packages/hoppscotch-app/helpers/codegen/generators/go-native.js

@@ -25,7 +25,7 @@ export const GoNativeCodegen = {
     const requestBody = rawInput ? rawParams : rawRequestBody
     if (method === "GET") {
       requestString.push(
-        `req, err := http.NewRequest("${method}", "${url}${pathName}${queryString}")\n`
+        `req, err := http.NewRequest("${method}", "${url}${pathName}?${queryString}")\n`
       )
     }
     if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
@@ -33,11 +33,11 @@ export const GoNativeCodegen = {
       if (isJSONContentType(contentType)) {
         requestString.push(`var reqBody = []byte(\`${requestBody}\`)\n\n`)
         requestString.push(
-          `req, err := http.NewRequest("${method}", "${url}${pathName}${queryString}", bytes.NewBuffer(reqBody))\n`
+          `req, err := http.NewRequest("${method}", "${url}${pathName}?${queryString}", bytes.NewBuffer(reqBody))\n`
         )
       } else if (contentType.includes("x-www-form-urlencoded")) {
         requestString.push(
-          `req, err := http.NewRequest("${method}", "${url}${pathName}${queryString}", strings.NewReader("${requestBody}"))\n`
+          `req, err := http.NewRequest("${method}", "${url}${pathName}?${queryString}", strings.NewReader("${requestBody}"))\n`
         )
       }
     }

+ 1 - 1
packages/hoppscotch-app/helpers/codegen/generators/java-okhttp.js

@@ -39,7 +39,7 @@ export const JavaOkhttpCodegen = {
     }
 
     requestString.push("Request request = new Request.Builder()")
-    requestString.push(`.url("${url}${pathName}${queryString}")`)
+    requestString.push(`.url("${url}${pathName}?${queryString}")`)
 
     if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
       requestString.push(`.method("${method}", body)`)

+ 1 - 1
packages/hoppscotch-app/helpers/codegen/generators/java-unirest.js

@@ -32,7 +32,7 @@ export const JavaUnirestCodegen = {
     // create client and request
     const verb = verbs.find((v) => v.verb === method)
     requestString.push(
-      `HttpResponse<String> response = Unirest.${verb.unirestMethod}("${url}${pathName}${queryString}")\n`
+      `HttpResponse<String> response = Unirest.${verb.unirestMethod}("${url}${pathName}?${queryString}")\n`
     )
     if (auth === "Basic Auth") {
       const basic = `${httpUser}:${httpPassword}`

+ 1 - 1
packages/hoppscotch-app/helpers/codegen/generators/javascript-fetch.js

@@ -21,7 +21,7 @@ export const JavascriptFetchCodegen = {
   }) => {
     const requestString = []
     let genHeaders = []
-    requestString.push(`fetch("${url}${pathName}${queryString}", {\n`)
+    requestString.push(`fetch("${url}${pathName}?${queryString}", {\n`)
     requestString.push(`  method: "${method}",\n`)
     if (auth === "Basic Auth") {
       const basic = `${httpUser}:${httpPassword}`

+ 1 - 1
packages/hoppscotch-app/helpers/codegen/generators/javascript-jquery.js

@@ -21,7 +21,7 @@ export const JavascriptJqueryCodegen = {
     const genHeaders = []
 
     requestString.push(
-      `jQuery.ajax({\n  url: "${url}${pathName}${queryString}"`
+      `jQuery.ajax({\n  url: "${url}${pathName}?${queryString}"`
     )
     requestString.push(`,\n  method: "${method.toUpperCase()}"`)
     const requestBody = rawInput ? rawParams : rawRequestBody

+ 1 - 1
packages/hoppscotch-app/helpers/codegen/generators/javascript-xhr.js

@@ -25,7 +25,7 @@ export const JavascriptXhrCodegen = {
     const user = auth === "Basic Auth" ? `'${httpUser}'` : null
     const password = auth === "Basic Auth" ? `'${httpPassword}'` : null
     requestString.push(
-      `xhr.open('${method}', '${url}${pathName}${queryString}', true, ${user}, ${password})`
+      `xhr.open('${method}', '${url}${pathName}?${queryString}', true, ${user}, ${password})`
     )
     if (auth === "Bearer Token" || auth === "OAuth 2.0") {
       requestString.push(

+ 1 - 1
packages/hoppscotch-app/helpers/codegen/generators/nodejs-axios.js

@@ -22,7 +22,7 @@ export const NodejsAxiosCodegen = {
     const requestBody = rawInput ? rawParams : rawRequestBody
 
     requestString.push(
-      `axios.${method.toLowerCase()}('${url}${pathName}${queryString}'`
+      `axios.${method.toLowerCase()}('${url}${pathName}?${queryString}'`
     )
     if (requestBody.length !== 0) {
       requestString.push(", ")

+ 1 - 1
packages/hoppscotch-app/helpers/codegen/generators/nodejs-native.js

@@ -24,7 +24,7 @@ export const NodejsNativeCodegen = {
 
     requestString.push(`const http = require('http');\n\n`)
 
-    requestString.push(`const url = '${url}${pathName}${queryString}';\n`)
+    requestString.push(`const url = '${url}${pathName}?${queryString}';\n`)
 
     requestString.push(`const options = {\n`)
     requestString.push(`  method: '${method}',\n`)

+ 1 - 1
packages/hoppscotch-app/helpers/codegen/generators/nodejs-request.js

@@ -25,7 +25,7 @@ export const NodejsRequestCodegen = {
     requestString.push(`const request = require('request');\n`)
     requestString.push(`const options = {\n`)
     requestString.push(`  method: '${method.toLowerCase()}',\n`)
-    requestString.push(`  url: '${url}${pathName}${queryString}'`)
+    requestString.push(`  url: '${url}${pathName}?${queryString}'`)
 
     if (auth === "Basic Auth") {
       const basic = `${httpUser}:${httpPassword}`

+ 1 - 1
packages/hoppscotch-app/helpers/codegen/generators/nodejs-unirest.js

@@ -25,7 +25,7 @@ export const NodejsUnirestCodegen = {
     requestString.push(`const unirest = require('unirest');\n`)
     requestString.push(`const req = unirest(\n`)
     requestString.push(
-      `'${method.toLowerCase()}', '${url}${pathName}${queryString}')\n`
+      `'${method.toLowerCase()}', '${url}${pathName}?${queryString}')\n`
     )
 
     if (auth === "Basic Auth") {

+ 1 - 1
packages/hoppscotch-app/helpers/codegen/generators/php-curl.js

@@ -25,7 +25,7 @@ export const PhpCurlCodegen = {
     requestString.push(`<?php\n`)
     requestString.push(`$curl = curl_init();\n`)
     requestString.push(`curl_setopt_array($curl, array(\n`)
-    requestString.push(`  CURLOPT_URL => "${url}${pathName}${queryString}",\n`)
+    requestString.push(`  CURLOPT_URL => "${url}${pathName}?${queryString}",\n`)
     requestString.push(`  CURLOPT_RETURNTRANSFER => true,\n`)
     requestString.push(`  CURLOPT_ENCODING => "",\n`)
     requestString.push(`  CURLOPT_MAXREDIRS => 10,\n`)

+ 1 - 1
packages/hoppscotch-app/helpers/codegen/generators/powershell-restmethod.js

@@ -26,7 +26,7 @@ export const PowershellRestmethodCodegen = {
     let variables = ""
 
     requestString.push(
-      `Invoke-RestMethod -Method '${formattedMethod}' -Uri '${url}${pathName}${queryString}'`
+      `Invoke-RestMethod -Method '${formattedMethod}' -Uri '${url}${pathName}?${queryString}'`
     )
     const requestBody = rawInput ? rawParams : rawRequestBody
 

+ 1 - 1
packages/hoppscotch-app/helpers/codegen/generators/python-http-client.js

@@ -91,7 +91,7 @@ export const PythonHttpClientCodegen = {
       }
     }
     requestString.push(
-      `conn.request("${method}", "${pathName}${queryString}", payload, headers)\n`
+      `conn.request("${method}", "${pathName}?${queryString}", payload, headers)\n`
     )
     requestString.push(`res = conn.getresponse()\n`)
     requestString.push(`data = res.read()\n`)

+ 3 - 3
packages/hoppscotch-app/helpers/codegen/generators/python-requests.js

@@ -31,7 +31,7 @@ export const PythonRequestsCodegen = {
     const genHeaders = []
 
     requestString.push(`import requests\n\n`)
-    requestString.push(`url = '${url}${pathName}${queryString}'\n`)
+    requestString.push(`url = '${url}${pathName}?${queryString}'\n`)
 
     // auth headers
     if (auth === "Basic Auth") {
@@ -58,7 +58,7 @@ export const PythonRequestsCodegen = {
       requestString.push(...printHeaders(genHeaders))
       requestString.push(`response = requests.request(\n`)
       requestString.push(`  '${method}',\n`)
-      requestString.push(`  '${url}${pathName}${queryString}',\n`)
+      requestString.push(`  '${url}${pathName}?${queryString}',\n`)
     }
     if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
       genHeaders.push(`'Content-Type': '${contentType}'`)
@@ -83,7 +83,7 @@ export const PythonRequestsCodegen = {
       }
       requestString.push(`response = requests.request(\n`)
       requestString.push(`  '${method}',\n`)
-      requestString.push(`  '${url}${pathName}${queryString}',\n`)
+      requestString.push(`  '${url}${pathName}?${queryString}',\n`)
       requestString.push(`  data=data,\n`)
     }
 

+ 1 - 1
packages/hoppscotch-app/helpers/codegen/generators/ruby-net-http.js

@@ -35,7 +35,7 @@ export const RubyNetHttpCodeGen = {
 
     // create URI and request
     const verb = verbs.find((v) => v.verb === method)
-    requestString.push(`uri = URI.parse('${url}${pathName}${queryString}')\n`)
+    requestString.push(`uri = URI.parse('${url}${pathName}?${queryString}')\n`)
     requestString.push(`request = Net::HTTP::${verb.rbMethod}.new(uri)`)
 
     // content type

+ 1 - 1
packages/hoppscotch-app/helpers/codegen/generators/salesforce-apex.js

@@ -30,7 +30,7 @@ export const SalesforceApexCodegen = {
     requestString.push(`HttpRequest request = new HttpRequest();\n`)
     requestString.push(`request.setMethod('${method}');\n`)
     requestString.push(
-      `request.setEndpoint('${url}${pathName}${queryString}');\n\n`
+      `request.setEndpoint('${url}${pathName}?${queryString}');\n\n`
     )
 
     // authentification

+ 1 - 1
packages/hoppscotch-app/helpers/codegen/generators/shell-httpie.js

@@ -37,7 +37,7 @@ export const ShellHttpieCodegen = {
     }
 
     // URL
-    let escapedUrl = `${url}${pathName}${queryString}`
+    let escapedUrl = `${url}${pathName}?${queryString}`
     escapedUrl = escapedUrl.replace(/'/g, "\\'")
     requestString.push(` ${method} $'${escapedUrl}'`)
 

+ 1 - 1
packages/hoppscotch-app/helpers/codegen/generators/shell-wget.js

@@ -19,7 +19,7 @@ export const ShellWgetCodegen = {
   }) => {
     const requestString = []
     requestString.push(`wget -O - --method=${method}`)
-    requestString.push(`  '${url}${pathName}${queryString}'`)
+    requestString.push(`  '${url}${pathName}?${queryString}'`)
     if (auth === "Basic Auth") {
       const basic = `${httpUser}:${httpPassword}`
       requestString.push(

+ 215 - 0
packages/hoppscotch-app/helpers/editor/codemirror.ts

@@ -0,0 +1,215 @@
+import CodeMirror from "codemirror"
+
+import "codemirror-theme-github/theme/github.css"
+import "codemirror/theme/base16-dark.css"
+import "codemirror/theme/tomorrow-night-bright.css"
+
+import "codemirror/lib/codemirror.css"
+import "codemirror/addon/lint/lint.css"
+import "codemirror/addon/dialog/dialog.css"
+import "codemirror/addon/hint/show-hint.css"
+
+import "codemirror/addon/fold/foldgutter.css"
+import "codemirror/addon/fold/foldgutter"
+import "codemirror/addon/fold/brace-fold"
+import "codemirror/addon/fold/comment-fold"
+import "codemirror/addon/fold/indent-fold"
+import "codemirror/addon/display/autorefresh"
+import "codemirror/addon/lint/lint"
+import "codemirror/addon/hint/show-hint"
+import "codemirror/addon/display/placeholder"
+import "codemirror/addon/edit/closebrackets"
+import "codemirror/addon/search/search"
+import "codemirror/addon/search/searchcursor"
+import "codemirror/addon/search/jump-to-line"
+import "codemirror/addon/dialog/dialog"
+import "codemirror/addon/selection/active-line"
+
+import { watch, onMounted, ref, Ref, useContext } from "@nuxtjs/composition-api"
+import { LinterDefinition } from "./linting/linter"
+import { Completer } from "./completion"
+
+type CodeMirrorOptions = {
+  extendedEditorConfig: Omit<CodeMirror.EditorConfiguration, "value">
+  linter: LinterDefinition | null
+  completer: Completer | null
+}
+
+const DEFAULT_EDITOR_CONFIG: CodeMirror.EditorConfiguration = {
+  autoRefresh: true,
+  lineNumbers: true,
+  foldGutter: true,
+  autoCloseBrackets: true,
+  gutters: ["CodeMirror-linenumbers", "CodeMirror-foldgutter"],
+  extraKeys: {
+    "Ctrl-Space": "autocomplete",
+  },
+  viewportMargin: Infinity,
+  styleActiveLine: true,
+}
+
+/**
+ * A Vue composable to mount and use Codemirror
+ *
+ * NOTE: Make sure to import all the necessary Codemirror modules,
+ * as this function doesn't import any other than the core
+ * @param el Reference to the dom node to attach to
+ * @param value Reference to value to read/write to
+ * @param options CodeMirror options to pass
+ */
+export function useCodemirror(
+  el: Ref<any | null>,
+  value: Ref<string>,
+  options: CodeMirrorOptions
+): { cm: Ref<CodeMirror.Position | null>; cursor: Ref<CodeMirror.Position> } {
+  const { $colorMode } = useContext() as any
+
+  const cm = ref<CodeMirror.Editor | null>(null)
+  const cursor = ref<CodeMirror.Position>({ line: 0, ch: 0 })
+
+  const updateEditorConfig = () => {
+    Object.keys(options.extendedEditorConfig).forEach((key) => {
+      // Only update options which need updating
+      if (
+        cm.value &&
+        cm.value?.getOption(key as any) !==
+          (options.extendedEditorConfig as any)[key]
+      ) {
+        cm.value?.setOption(
+          key as any,
+          (options.extendedEditorConfig as any)[key]
+        )
+      }
+    })
+  }
+
+  const updateLinterConfig = () => {
+    if (options.linter) {
+      cm.value?.setOption("lint", options.linter)
+    }
+  }
+
+  const updateCompleterConfig = () => {
+    if (options.completer) {
+      cm.value?.setOption("hintOptions", {
+        completeSingle: false,
+        hint: async (editor: CodeMirror.Editor) => {
+          const pos = editor.getCursor()
+          const text = editor.getValue()
+
+          const token = editor.getTokenAt(pos)
+          // It's not a word token, so, just increment to skip to next
+          if (token.string.toUpperCase() === token.string.toLowerCase())
+            token.start += 1
+
+          const result = await options.completer!(text, pos)
+
+          if (!result) return null
+
+          return <CodeMirror.Hints>{
+            from: { line: pos.line, ch: token.start },
+            to: { line: pos.line, ch: token.end },
+            list: result.completions
+              .sort((a, b) => a.score - b.score)
+              .map((x) => x.text),
+          }
+        },
+      })
+    }
+  }
+
+  const initialize = () => {
+    if (!el.value) return
+
+    cm.value = CodeMirror(el.value!, DEFAULT_EDITOR_CONFIG)
+
+    cm.value.setValue(value.value)
+
+    setTheme()
+    updateEditorConfig()
+    updateLinterConfig()
+    updateCompleterConfig()
+
+    cm.value.on("change", (instance) => {
+      // External update propagation (via watchers) should be ignored
+      if (instance.getValue() !== value.value) {
+        value.value = instance.getValue()
+      }
+    })
+
+    cm.value.on("cursorActivity", (instance) => {
+      cursor.value = instance.getCursor()
+    })
+  }
+
+  // Boot-up CodeMirror, set the value and listeners
+  onMounted(() => {
+    initialize()
+  })
+
+  // Reinitialize if the target ref updates
+  watch(el, () => {
+    if (cm.value) {
+      const parent = cm.value.getWrapperElement()
+      parent.remove()
+      cm.value = null
+    }
+    initialize()
+  })
+
+  const setTheme = () => {
+    if (cm.value) {
+      cm.value?.setOption("theme", getThemeName($colorMode.value))
+    }
+  }
+
+  const getThemeName = (mode: string) => {
+    switch (mode) {
+      case "system":
+        return "default"
+      case "light":
+        return "github"
+      case "dark":
+        return "base16-dark"
+      case "black":
+        return "tomorrow-night-bright"
+      default:
+        return "default"
+    }
+  }
+
+  // If the editor properties are reactive, watch for updates
+  watch(() => options.extendedEditorConfig, updateEditorConfig, {
+    immediate: true,
+    deep: true,
+  })
+  watch(() => options.linter, updateLinterConfig, { immediate: true })
+  watch(() => options.completer, updateCompleterConfig, { immediate: true })
+
+  // Watch value updates
+  watch(value, (newVal) => {
+    // Check if we are mounted
+    if (cm.value) {
+      // Don't do anything on internal updates
+      if (cm.value.getValue() !== newVal) {
+        cm.value.setValue(newVal)
+      }
+    }
+  })
+
+  // Push cursor updates
+  watch(cursor, (value) => {
+    if (value !== cm.value?.getCursor()) {
+      cm.value?.focus()
+      cm.value?.setCursor(value)
+    }
+  })
+
+  // Watch color mode updates and update theme
+  watch(() => $colorMode.value, setTheme)
+
+  return {
+    cm,
+    cursor,
+  }
+}

+ 27 - 0
packages/hoppscotch-app/helpers/editor/completion/gqlQuery.ts

@@ -0,0 +1,27 @@
+import { Ref } from "@nuxtjs/composition-api"
+import { GraphQLSchema } from "graphql"
+import { getAutocompleteSuggestions } from "graphql-language-service-interface"
+import { Completer, CompleterResult, CompletionEntry } from "."
+
+const completer: (schemaRef: Ref<GraphQLSchema | null>) => Completer =
+  (schemaRef: Ref<GraphQLSchema | null>) => (text, completePos) => {
+    if (!schemaRef.value) return Promise.resolve(null)
+
+    const completions = getAutocompleteSuggestions(schemaRef.value, text, {
+      line: completePos.line,
+      character: completePos.ch,
+    } as any)
+
+    return Promise.resolve(<CompleterResult>{
+      completions: completions.map(
+        (x, i) =>
+          <CompletionEntry>{
+            text: x.label!,
+            meta: x.detail!,
+            score: completions.length - i,
+          }
+      ),
+    })
+  }
+
+export default completer

+ 23 - 0
packages/hoppscotch-app/helpers/editor/completion/index.ts

@@ -0,0 +1,23 @@
+export type CompletionEntry = {
+  text: string
+  meta: string
+  score: number
+}
+
+export type CompleterResult = {
+  /**
+   * List of completions to display
+   */
+  completions: CompletionEntry[]
+}
+
+export type Completer = (
+  /**
+   * The contents of the editor
+   */
+  text: string,
+  /**
+   * Position where the completer is fired
+   */
+  completePos: { line: number; ch: number }
+) => Promise<CompleterResult | null>

+ 24 - 0
packages/hoppscotch-app/helpers/editor/completion/preRequest.ts

@@ -0,0 +1,24 @@
+import { Completer, CompletionEntry } from "."
+import { getPreRequestScriptCompletions } from "~/helpers/tern"
+
+const completer: Completer = async (text, completePos) => {
+  const results = await getPreRequestScriptCompletions(
+    text,
+    completePos.line,
+    completePos.ch
+  )
+
+  const completions = results.completions.map((completion: any, i: number) => {
+    return <CompletionEntry>{
+      text: completion.name,
+      meta: completion.isKeyword ? "keyword" : completion.type,
+      score: results.completions.length - i,
+    }
+  })
+
+  return {
+    completions,
+  }
+}
+
+export default completer

+ 24 - 0
packages/hoppscotch-app/helpers/editor/completion/testScript.ts

@@ -0,0 +1,24 @@
+import { Completer, CompletionEntry } from "."
+import { getTestScriptCompletions } from "~/helpers/tern"
+
+export const completer: Completer = async (text, completePos) => {
+  const results = await getTestScriptCompletions(
+    text,
+    completePos.line,
+    completePos.ch
+  )
+
+  const completions = results.completions.map((completion: any, i: number) => {
+    return <CompletionEntry>{
+      text: completion.name,
+      meta: completion.isKeyword ? "keyword" : completion.type,
+      score: results.completions.length - i,
+    }
+  })
+
+  return {
+    completions,
+  }
+}
+
+export default completer

+ 58 - 0
packages/hoppscotch-app/helpers/editor/linting/gqlQuery.ts

@@ -0,0 +1,58 @@
+import { Ref } from "@nuxtjs/composition-api"
+import {
+  GraphQLError,
+  GraphQLSchema,
+  parse as gqlParse,
+  validate as gqlValidate,
+} from "graphql"
+import { LinterDefinition, LinterResult } from "./linter"
+
+/**
+ * Creates a Linter function that can lint a GQL query against a given
+ * schema
+ */
+export const createGQLQueryLinter: (
+  schema: Ref<GraphQLSchema | null>
+) => LinterDefinition = (schema: Ref<GraphQLSchema | null>) => (text) => {
+  if (text === "") return Promise.resolve([])
+  if (!schema.value) return Promise.resolve([])
+
+  try {
+    const doc = gqlParse(text)
+
+    const results = gqlValidate(schema.value, doc).map(
+      ({ locations, message }) =>
+        <LinterResult>{
+          from: {
+            line: locations![0].line - 1,
+            ch: locations![0].column - 1,
+          },
+          to: {
+            line: locations![0].line - 1,
+            ch: locations![0].column,
+          },
+          message,
+          severity: "error",
+        }
+    )
+
+    return Promise.resolve(results)
+  } catch (e) {
+    const err = e as GraphQLError
+
+    return Promise.resolve([
+      <LinterResult>{
+        from: {
+          line: err.locations![0].line - 1,
+          ch: err.locations![0].column - 1,
+        },
+        to: {
+          line: err.locations![0].line - 1,
+          ch: err.locations![0].column,
+        },
+        message: err.message,
+        severity: "error",
+      },
+    ])
+  }
+}

+ 21 - 0
packages/hoppscotch-app/helpers/editor/linting/json.ts

@@ -0,0 +1,21 @@
+import { convertIndexToLineCh } from "../utils"
+import { LinterDefinition, LinterResult } from "./linter"
+import jsonParse from "~/helpers/jsonParse"
+
+const linter: LinterDefinition = (text) => {
+  try {
+    jsonParse(text)
+    return Promise.resolve([])
+  } catch (e: any) {
+    return Promise.resolve([
+      <LinterResult>{
+        from: convertIndexToLineCh(text, e.start),
+        to: convertIndexToLineCh(text, e.end),
+        message: e.message,
+        severity: "error",
+      },
+    ])
+  }
+}
+
+export default linter

+ 7 - 0
packages/hoppscotch-app/helpers/editor/linting/linter.ts

@@ -0,0 +1,7 @@
+export type LinterResult = {
+  message: string
+  severity: "warning" | "error"
+  from: { line: number; ch: number }
+  to: { line: number; ch: number }
+}
+export type LinterDefinition = (text: string) => Promise<LinterResult[]>

+ 69 - 0
packages/hoppscotch-app/helpers/editor/linting/preRequest.ts

@@ -0,0 +1,69 @@
+import * as esprima from "esprima"
+import { LinterDefinition, LinterResult } from "./linter"
+import { performPreRequestLinting } from "~/helpers/tern"
+
+const linter: LinterDefinition = async (text) => {
+  let results: LinterResult[] = []
+
+  // Semantic linting
+  const semanticLints = await performPreRequestLinting(text)
+
+  results = results.concat(
+    semanticLints.map((lint: any) => ({
+      from: lint.from,
+      to: lint.to,
+      severity: "error",
+      message: `[semantic] ${lint.message}`,
+    }))
+  )
+
+  // Syntax linting
+  try {
+    const res: any = esprima.parseScript(text, { tolerant: true })
+    if (res.errors && res.errors.length > 0) {
+      results = results.concat(
+        res.errors.map((err: any) => {
+          const fromPos: { line: number; ch: number } = {
+            line: err.lineNumber - 1,
+            ch: err.column - 1,
+          }
+
+          const toPos: { line: number; ch: number } = {
+            line: err.lineNumber - 1,
+            ch: err.column,
+          }
+
+          return <LinterResult>{
+            from: fromPos,
+            to: toPos,
+            message: `[syntax] ${err.description}`,
+            severity: "error",
+          }
+        })
+      )
+    }
+  } catch (e) {
+    const fromPos: { line: number; ch: number } = {
+      line: e.lineNumber - 1,
+      ch: e.column - 1,
+    }
+
+    const toPos: { line: number; ch: number } = {
+      line: e.lineNumber - 1,
+      ch: e.column,
+    }
+
+    results = results.concat([
+      <LinterResult>{
+        from: fromPos,
+        to: toPos,
+        message: `[syntax] ${e.description}`,
+        severity: "error",
+      },
+    ])
+  }
+
+  return results
+}
+
+export default linter

+ 69 - 0
packages/hoppscotch-app/helpers/editor/linting/testScript.ts

@@ -0,0 +1,69 @@
+import * as esprima from "esprima"
+import { LinterDefinition, LinterResult } from "./linter"
+import { performTestLinting } from "~/helpers/tern"
+
+const linter: LinterDefinition = async (text) => {
+  let results: LinterResult[] = []
+
+  // Semantic linting
+  const semanticLints = await performTestLinting(text)
+
+  results = results.concat(
+    semanticLints.map((lint: any) => ({
+      from: lint.from,
+      to: lint.to,
+      severity: "error",
+      message: `[semantic] ${lint.message}`,
+    }))
+  )
+
+  // Syntax linting
+  try {
+    const res: any = esprima.parseScript(text, { tolerant: true })
+    if (res.errors && res.errors.length > 0) {
+      results = results.concat(
+        res.errors.map((err: any) => {
+          const fromPos: { line: number; ch: number } = {
+            line: err.lineNumber - 1,
+            ch: err.column - 1,
+          }
+
+          const toPos: { line: number; ch: number } = {
+            line: err.lineNumber - 1,
+            ch: err.column,
+          }
+
+          return <LinterResult>{
+            from: fromPos,
+            to: toPos,
+            message: `[syntax] ${err.description}`,
+            severity: "error",
+          }
+        })
+      )
+    }
+  } catch (e) {
+    const fromPos: { line: number; ch: number } = {
+      line: e.lineNumber - 1,
+      ch: e.column - 1,
+    }
+
+    const toPos: { line: number; ch: number } = {
+      line: e.lineNumber - 1,
+      ch: e.column,
+    }
+
+    results = results.concat([
+      <LinterResult>{
+        from: fromPos,
+        to: toPos,
+        message: `[syntax] ${e.description}`,
+        severity: "error",
+      },
+    ])
+  }
+
+  return results
+}
+
+export default linter

+ 80 - 0
packages/hoppscotch-app/helpers/editor/modes/graphql.ts

@@ -0,0 +1,80 @@
+/**
+ *  Copyright (c) 2021 GraphQL Contributors
+ *  All rights reserved.
+ *
+ *  This source code is licensed under the BSD-style license found in the
+ *  LICENSE file in the root directory of this source tree. An additional grant
+ *  of patent rights can be found in the PATENTS file in the same directory.
+ */
+
+import CodeMirror from "codemirror"
+import {
+  LexRules,
+  ParseRules,
+  isIgnored,
+  onlineParser,
+  State,
+} from "graphql-language-service-parser"
+
+/**
+ * The GraphQL mode is defined as a tokenizer along with a list of rules, each
+ * of which is either a function or an array.
+ *
+ *   * Function: Provided a token and the stream, returns an expected next step.
+ *   * Array: A list of steps to take in order.
+ *
+ * A step is either another rule, or a terminal description of a token. If it
+ * is a rule, that rule is pushed onto the stack and the parsing continues from
+ * that point.
+ *
+ * If it is a terminal description, the token is checked against it using a
+ * `match` function. If the match is successful, the token is colored and the
+ * rule is stepped forward. If the match is unsuccessful, the remainder of the
+ * rule is skipped and the previous rule is advanced.
+ *
+ * This parsing algorithm allows for incremental online parsing within various
+ * levels of the syntax tree and results in a structured `state` linked-list
+ * which contains the relevant information to produce valuable typeaheads.
+ */
+CodeMirror.defineMode("graphql", (config) => {
+  const parser = onlineParser({
+    eatWhitespace: (stream) => stream.eatWhile(isIgnored),
+    lexRules: LexRules,
+    parseRules: ParseRules,
+    editorConfig: { tabSize: 2 },
+  })
+
+  return {
+    config,
+    startState: parser.startState,
+    token: parser.token as unknown as CodeMirror.Mode<any>["token"], // TODO: Check if the types are indeed compatible
+    indent,
+    electricInput: /^\s*[})\]]/,
+    fold: "brace",
+    lineComment: "#",
+    closeBrackets: {
+      pairs: '()[]{}""',
+      explode: "()[]{}",
+    },
+  }
+})
+
+// Seems the electricInput type in @types/codemirror is wrong (i.e it is written as electricinput instead of electricInput)
+function indent(
+  this: CodeMirror.Mode<any> & {
+    electricInput?: RegExp
+    config?: CodeMirror.EditorConfiguration
+  },
+  state: State,
+  textAfter: string
+) {
+  const levels = state.levels
+  // If there is no stack of levels, use the current level.
+  // Otherwise, use the top level, pre-emptively dedenting for close braces.
+  const level =
+    !levels || levels.length === 0
+      ? state.indentLevel
+      : levels[levels.length - 1] -
+        (this.electricInput?.test(textAfter) ? 1 : 0)
+  return (level || 0) * (this.config?.indentUnit || 0)
+}

+ 38 - 0
packages/hoppscotch-app/helpers/editor/utils.ts

@@ -0,0 +1,38 @@
+export function convertIndexToLineCh(
+  text: string,
+  i: number
+): { line: number; ch: number } {
+  const lines = text.split("\n")
+
+  let line = 0
+  let counter = 0
+
+  while (line < lines.length) {
+    if (i > lines[line].length + counter) {
+      counter += lines[line].length + 1
+      line++
+    } else {
+      return {
+        line: line + 1,
+        ch: i - counter + 1,
+      }
+    }
+  }
+
+  throw new Error("Invalid input")
+}
+
+export function convertLineChToIndex(
+  text: string,
+  lineCh: { line: number; ch: number }
+): number {
+  const textSplit = text.split("\n")
+
+  if (textSplit.length < lineCh.line) throw new Error("Invalid position")
+
+  const tillLineIndex = textSplit
+    .slice(0, lineCh.line)
+    .reduce((acc, line) => acc + line.length + 1, 0)
+
+  return tillLineIndex + lineCh.ch
+}

+ 7 - 7
packages/hoppscotch-app/helpers/editorutils.js

@@ -1,12 +1,12 @@
 const mimeToMode = {
-  "text/plain": "plain_text",
-  "text/html": "html",
-  "application/xml": "xml",
-  "application/hal+json": "json",
-  "application/vnd.api+json": "json",
-  "application/json": "json",
+  "text/plain": "text/x-yaml",
+  "text/html": "htmlmixed",
+  "application/xml": "application/xml",
+  "application/hal+json": "application/ld+json",
+  "application/vnd.api+json": "application/ld+json",
+  "application/json": "application/ld+json",
 }
 
 export function getEditorLangForMimeType(mimeType) {
-  return mimeToMode[mimeType] || "plain_text"
+  return mimeToMode[mimeType] || "text/x-yaml"
 }

+ 102 - 23
packages/hoppscotch-app/helpers/jsonParse.js → packages/hoppscotch-app/helpers/jsonParse.ts

@@ -19,7 +19,75 @@
  *   - end: int - the end exclusive offset of the syntax error
  *
  */
-export default function jsonParse(str) {
+type JSONEOFValue = {
+  kind: "EOF"
+  start: number
+  end: number
+}
+
+type JSONNullValue = {
+  kind: "Null"
+  start: number
+  end: number
+}
+
+type JSONNumberValue = {
+  kind: "Number"
+  start: number
+  end: number
+  value: number
+}
+
+type JSONStringValue = {
+  kind: "String"
+  start: number
+  end: number
+  value: string
+}
+
+type JSONBooleanValue = {
+  kind: "Boolean"
+  start: number
+  end: number
+  value: boolean
+}
+
+type JSONPrimitiveValue =
+  | JSONNullValue
+  | JSONEOFValue
+  | JSONStringValue
+  | JSONNumberValue
+  | JSONBooleanValue
+
+export type JSONObjectValue = {
+  kind: "Object"
+  start: number
+  end: number
+  // eslint-disable-next-line no-use-before-define
+  members: JSONObjectMember[]
+}
+
+export type JSONArrayValue = {
+  kind: "Array"
+  start: number
+  end: number
+  // eslint-disable-next-line no-use-before-define
+  values: JSONValue[]
+}
+
+export type JSONValue = JSONObjectValue | JSONArrayValue | JSONPrimitiveValue
+
+export type JSONObjectMember = {
+  kind: "Member"
+  start: number
+  end: number
+  key: JSONStringValue
+  value: JSONValue
+}
+
+export default function jsonParse(
+  str: string
+): JSONObjectValue | JSONArrayValue {
   string = str
   strLen = str.length
   start = end = lastEnd = -1
@@ -37,15 +105,15 @@ export default function jsonParse(str) {
   }
 }
 
-let string
-let strLen
-let start
-let end
-let lastEnd
-let code
-let kind
+let string: string
+let strLen: number
+let start: number
+let end: number
+let lastEnd: number
+let code: number
+let kind: string
 
-function parseObj() {
+function parseObj(): JSONObjectValue {
   const nodeStart = start
   const members = []
   expect("{")
@@ -63,9 +131,9 @@ function parseObj() {
   }
 }
 
-function parseMember() {
+function parseMember(): JSONObjectMember {
   const nodeStart = start
-  const key = kind === "String" ? curToken() : null
+  const key = kind === "String" ? (curToken() as JSONStringValue) : null
   expect("String")
   expect(":")
   const value = parseVal()
@@ -73,14 +141,14 @@ function parseMember() {
     kind: "Member",
     start: nodeStart,
     end: lastEnd,
-    key,
+    key: key!,
     value,
   }
 }
 
-function parseArr() {
+function parseArr(): JSONArrayValue {
   const nodeStart = start
-  const values = []
+  const values: JSONValue[] = []
   expect("[")
   if (!skip("]")) {
     do {
@@ -96,7 +164,7 @@ function parseArr() {
   }
 }
 
-function parseVal() {
+function parseVal(): JSONValue {
   switch (kind) {
     case "[":
       return parseArr()
@@ -111,14 +179,19 @@ function parseVal() {
       lex()
       return token
   }
-  return expect("Value")
+  return expect("Value") as never
 }
 
-function curToken() {
-  return { kind, start, end, value: JSON.parse(string.slice(start, end)) }
+function curToken(): JSONPrimitiveValue {
+  return {
+    kind: kind as any,
+    start,
+    end,
+    value: JSON.parse(string.slice(start, end)),
+  }
 }
 
-function expect(str) {
+function expect(str: string) {
   if (kind === str) {
     lex()
     return
@@ -137,11 +210,17 @@ function expect(str) {
   throw syntaxError(`Expected ${str} but found ${found}.`)
 }
 
-function syntaxError(message) {
+type SyntaxError = {
+  message: string
+  start: number
+  end: number
+}
+
+function syntaxError(message: string): SyntaxError {
   return { message, start, end }
 }
 
-function skip(k) {
+function skip(k: string) {
   if (kind === k) {
     lex()
     return true
@@ -227,7 +306,7 @@ function lex() {
 function readString() {
   ch()
   while (code !== 34 && code > 31) {
-    if (code === 92) {
+    if (code === (92 as any)) {
       // \
       ch()
       switch (code) {
@@ -299,7 +378,7 @@ function readNumber() {
   if (code === 69 || code === 101) {
     // E e
     ch()
-    if (code === 43 || code === 45) {
+    if (code === (43 as any) || code === (45 as any)) {
       // + -
       ch()
     }

+ 4 - 2
packages/hoppscotch-app/helpers/lenses/htmlLens.js → packages/hoppscotch-app/helpers/lenses/htmlLens.ts

@@ -1,10 +1,12 @@
-const htmlLens = {
+import { Lens } from "./lenses"
+
+const htmlLens: Lens = {
   lensName: "response.html",
   isSupportedContentType: (contentType) =>
     /\btext\/html|application\/xhtml\+xml\b/i.test(contentType),
   renderer: "htmlres",
   rendererImport: () =>
-    import("~/components/lenses/renderers/HTMLLensRenderer"),
+    import("~/components/lenses/renderers/HTMLLensRenderer.vue"),
 }
 
 export default htmlLens

+ 4 - 2
packages/hoppscotch-app/helpers/lenses/imageLens.js → packages/hoppscotch-app/helpers/lenses/imageLens.ts

@@ -1,4 +1,6 @@
-const imageLens = {
+import { Lens } from "./lenses"
+
+const imageLens: Lens = {
   lensName: "response.image",
   isSupportedContentType: (contentType) =>
     /\bimage\/(?:gif|jpeg|png|bmp|svg\+xml|x-icon|vnd\.microsoft\.icon)\b/i.test(
@@ -6,7 +8,7 @@ const imageLens = {
     ),
   renderer: "imageres",
   rendererImport: () =>
-    import("~/components/lenses/renderers/ImageLensRenderer"),
+    import("~/components/lenses/renderers/ImageLensRenderer.vue"),
 }
 
 export default imageLens

+ 3 - 2
packages/hoppscotch-app/helpers/lenses/jsonLens.js → packages/hoppscotch-app/helpers/lenses/jsonLens.ts

@@ -1,11 +1,12 @@
 import { isJSONContentType } from "../utils/contenttypes"
+import { Lens } from "./lenses"
 
-const jsonLens = {
+const jsonLens: Lens = {
   lensName: "response.json",
   isSupportedContentType: isJSONContentType,
   renderer: "json",
   rendererImport: () =>
-    import("~/components/lenses/renderers/JSONLensRenderer"),
+    import("~/components/lenses/renderers/JSONLensRenderer.vue"),
 }
 
 export default jsonLens

+ 0 - 28
packages/hoppscotch-app/helpers/lenses/lenses.js

@@ -1,28 +0,0 @@
-import jsonLens from "./jsonLens"
-import rawLens from "./rawLens"
-import imageLens from "./imageLens"
-import htmlLens from "./htmlLens"
-import xmlLens from "./xmlLens"
-
-export const lenses = [jsonLens, imageLens, htmlLens, xmlLens, rawLens]
-
-export function getSuitableLenses(response) {
-  const contentType = response.headers.find((h) => h.key === "content-type")
-
-  if (!contentType) return [rawLens]
-
-  const result = []
-  for (const lens of lenses) {
-    if (lens.isSupportedContentType(contentType.value)) result.push(lens)
-  }
-
-  return result
-}
-
-export function getLensRenderers() {
-  const response = {}
-  for (const lens of lenses) {
-    response[lens.renderer] = lens.rendererImport
-  }
-  return response
-}

+ 42 - 0
packages/hoppscotch-app/helpers/lenses/lenses.ts

@@ -0,0 +1,42 @@
+import { HoppRESTResponse } from "../types/HoppRESTResponse"
+import jsonLens from "./jsonLens"
+import rawLens from "./rawLens"
+import imageLens from "./imageLens"
+import htmlLens from "./htmlLens"
+import xmlLens from "./xmlLens"
+
+export type Lens = {
+  lensName: string
+  isSupportedContentType: (contentType: string) => boolean
+  renderer: string
+  rendererImport: () => Promise<typeof import("*.vue")>
+}
+
+export const lenses: Lens[] = [jsonLens, imageLens, htmlLens, xmlLens, rawLens]
+
+export function getSuitableLenses(response: HoppRESTResponse): Lens[] {
+  // return empty array if response is loading or error
+  if (response.type === "loading" || response.type === "network_fail") return []
+
+  const contentType = response.headers.find((h) => h.key === "content-type")
+
+  if (!contentType) return [rawLens]
+
+  const result = []
+  for (const lens of lenses) {
+    if (lens.isSupportedContentType(contentType.value)) result.push(lens)
+  }
+  return result
+}
+
+type LensRenderers = {
+  [key: string]: Lens["rendererImport"]
+}
+
+export function getLensRenderers(): LensRenderers {
+  const response: LensRenderers = {}
+  for (const lens of lenses) {
+    response[lens.renderer] = lens.rendererImport
+  }
+  return response
+}

+ 0 - 8
packages/hoppscotch-app/helpers/lenses/rawLens.js

@@ -1,8 +0,0 @@
-const rawLens = {
-  lensName: "response.raw",
-  isSupportedContentType: () => true,
-  renderer: "raw",
-  rendererImport: () => import("~/components/lenses/renderers/RawLensRenderer"),
-}
-
-export default rawLens

+ 11 - 0
packages/hoppscotch-app/helpers/lenses/rawLens.ts

@@ -0,0 +1,11 @@
+import { Lens } from "./lenses"
+
+const rawLens: Lens = {
+  lensName: "response.raw",
+  isSupportedContentType: () => true,
+  renderer: "raw",
+  rendererImport: () =>
+    import("~/components/lenses/renderers/RawLensRenderer.vue"),
+}
+
+export default rawLens

+ 5 - 2
packages/hoppscotch-app/helpers/lenses/xmlLens.js → packages/hoppscotch-app/helpers/lenses/xmlLens.ts

@@ -1,8 +1,11 @@
-const xmlLens = {
+import { Lens } from "./lenses"
+
+const xmlLens: Lens = {
   lensName: "response.xml",
   isSupportedContentType: (contentType) => /\bxml\b/i.test(contentType),
   renderer: "xmlres",
-  rendererImport: () => import("~/components/lenses/renderers/XMLLensRenderer"),
+  rendererImport: () =>
+    import("~/components/lenses/renderers/XMLLensRenderer.vue"),
 }
 
 export default xmlLens

+ 100 - 0
packages/hoppscotch-app/helpers/newOutline.ts

@@ -0,0 +1,100 @@
+import {
+  JSONArrayValue,
+  JSONObjectMember,
+  JSONObjectValue,
+  JSONValue,
+} from "./jsonParse"
+
+type RootEntry =
+  | {
+      kind: "RootObject"
+      astValue: JSONObjectValue
+    }
+  | {
+      kind: "RootArray"
+      astValue: JSONArrayValue
+    }
+
+type ObjectMemberEntry = {
+  kind: "ObjectMember"
+  name: string
+  astValue: JSONObjectMember
+  astParent: JSONObjectValue
+}
+
+type ArrayMemberEntry = {
+  kind: "ArrayMember"
+  index: number
+  astValue: JSONValue
+  astParent: JSONArrayValue
+}
+
+type PathEntry = RootEntry | ObjectMemberEntry | ArrayMemberEntry
+
+export function getJSONOutlineAtPos(
+  jsonRootAst: JSONObjectValue | JSONArrayValue,
+  posIndex: number
+): PathEntry[] | null {
+  try {
+    const rootObj = jsonRootAst
+
+    if (posIndex > rootObj.end || posIndex < rootObj.start)
+      throw new Error("Invalid position")
+
+    let current: JSONValue = rootObj
+
+    const path: PathEntry[] = []
+
+    if (rootObj.kind === "Object") {
+      path.push({
+        kind: "RootObject",
+        astValue: rootObj,
+      })
+    } else {
+      path.push({
+        kind: "RootArray",
+        astValue: rootObj,
+      })
+    }
+
+    while (current.kind === "Object" || current.kind === "Array") {
+      if (current.kind === "Object") {
+        const next: JSONObjectMember | undefined = current.members.find(
+          (member) => member.start <= posIndex && member.end >= posIndex
+        )
+
+        if (!next) throw new Error("Couldn't find child")
+
+        path.push({
+          kind: "ObjectMember",
+          name: next.key.value,
+          astValue: next,
+          astParent: current,
+        })
+
+        current = next.value
+      } else {
+        const nextIndex = current.values.findIndex(
+          (value) => value.start <= posIndex && value.end >= posIndex
+        )
+
+        if (nextIndex < 0) throw new Error("Couldn't find child")
+
+        const next: JSONValue = current.values[nextIndex]
+
+        path.push({
+          kind: "ArrayMember",
+          index: nextIndex,
+          astValue: next,
+          astParent: current,
+        })
+
+        current = next
+      }
+    }
+
+    return path
+  } catch (e: any) {
+    return null
+  }
+}

+ 0 - 124
packages/hoppscotch-app/helpers/outline.js

@@ -1,124 +0,0 @@
-import jsonParse from "./jsonParse"
-
-export default () => {
-  let jsonAST = {}
-  let path = []
-
-  const init = (jsonStr) => {
-    jsonAST = jsonParse(jsonStr)
-    linkParents(jsonAST)
-  }
-
-  const setNewText = (jsonStr) => {
-    init(jsonStr)
-    path = []
-  }
-
-  const linkParents = (node) => {
-    if (node.kind === "Object") {
-      if (node.members) {
-        node.members.forEach((m) => {
-          m.parent = node
-          linkParents(m)
-        })
-      }
-    } else if (node.kind === "Array") {
-      if (node.values) {
-        node.values.forEach((v) => {
-          v.parent = node
-          linkParents(v)
-        })
-      }
-    } else if (node.kind === "Member") {
-      if (node.value) {
-        node.value.parent = node
-        linkParents(node.value)
-      }
-    }
-  }
-
-  const genPath = (index) => {
-    let output = {}
-    path = []
-    let current = jsonAST
-    if (current.kind === "Object") {
-      path.push({ label: "{}", obj: "root" })
-    } else if (current.kind === "Array") {
-      path.push({ label: "[]", obj: "root" })
-    }
-    let over = false
-
-    try {
-      while (!over) {
-        if (current.kind === "Object") {
-          let i = 0
-          let found = false
-          while (i < current.members.length) {
-            const m = current.members[i]
-            if (m.start <= index && m.end >= index) {
-              path.push({ label: m.key.value, obj: m })
-              current = current.members[i]
-              found = true
-              break
-            }
-            i++
-          }
-          if (!found) over = true
-        } else if (current.kind === "Array") {
-          if (current.values) {
-            let i = 0
-            let found = false
-            while (i < current.values.length) {
-              const m = current.values[i]
-              if (m.start <= index && m.end >= index) {
-                path.push({ label: `[${i.toString()}]`, obj: m })
-                current = current.values[i]
-                found = true
-                break
-              }
-              i++
-            }
-            if (!found) over = true
-          } else over = true
-        } else if (current.kind === "Member") {
-          if (current.value) {
-            if (current.value.start <= index && current.value.end >= index) {
-              current = current.value
-            } else over = true
-          } else over = true
-        } else if (
-          current.kind === "String" ||
-          current.kind === "Number" ||
-          current.kind === "Boolean" ||
-          current.kind === "Null"
-        ) {
-          if (current.start <= index && current.end >= index) {
-            path.push({ label: `${current.value}`, obj: current })
-          }
-          over = true
-        }
-      }
-      output = { success: true, res: path.map((p) => p.label) }
-    } catch (e) {
-      output = { success: false, res: e }
-    }
-    return output
-  }
-
-  const getSiblings = (index) => {
-    const parent = path[index]?.obj?.parent
-    if (!parent) return []
-    else if (parent.kind === "Object") {
-      return parent.members
-    } else if (parent.kind === "Array") {
-      return parent.values
-    } else return []
-  }
-
-  return {
-    init,
-    genPath,
-    getSiblings,
-    setNewText,
-  }
-}

+ 8 - 0
packages/hoppscotch-app/helpers/types/HoppRequestSaveContext.ts

@@ -28,4 +28,12 @@ export type HoppRequestSaveContext =
        * ID of the request in the team
        */
       requestID: string
+      /**
+       * ID of the team
+       */
+      teamID?: string
+      /**
+       * ID of the collection loaded
+       */
+      collectionID?: string
     }

+ 3 - 3
packages/hoppscotch-app/helpers/utils/StreamUtils.ts

@@ -8,9 +8,9 @@ import { map } from "rxjs/operators"
  *
  * @returns The constructed object observable
  */
-export function constructFromStreams<T>(
-  streamObj: { [key in keyof T]: Observable<T[key]> }
-): Observable<T> {
+export function constructFromStreams<T>(streamObj: {
+  [key in keyof T]: Observable<T[key]>
+}): Observable<T> {
   return combineLatest(Object.values<Observable<T[keyof T]>>(streamObj)).pipe(
     map((streams) => {
       const keys = Object.keys(streamObj) as (keyof T)[]

+ 1 - 1
packages/hoppscotch-app/helpers/utils/b64.js → packages/hoppscotch-app/helpers/utils/b64.ts

@@ -1,4 +1,4 @@
-export const decodeB64StringToArrayBuffer = (input) => {
+export function decodeB64StringToArrayBuffer(input: string): ArrayBuffer {
   const bytes = Math.floor((input.length / 4) * 3)
   const ab = new ArrayBuffer(bytes)
   const uarray = new Uint8Array(ab)

+ 0 - 12
packages/hoppscotch-app/helpers/utils/string.js

@@ -1,12 +0,0 @@
-export function getSourcePrefix(source) {
-  const sourceEmojis = {
-    // Source used for info messages.
-    info: "\tℹ️ [INFO]:\t",
-    // Source used for client to server messages.
-    client: "\t⬅️ [SENT]:\t",
-    // Source used for server to client messages.
-    server: "\t➡️ [RECEIVED]:\t",
-  }
-  if (Object.keys(sourceEmojis).includes(source)) return sourceEmojis[source]
-  return ""
-}

+ 12 - 0
packages/hoppscotch-app/helpers/utils/string.ts

@@ -0,0 +1,12 @@
+const sourceEmojis = {
+  // Source used for info messages.
+  info: "\tℹ️ [INFO]:\t",
+  // Source used for client to server messages.
+  client: "\t⬅️ [SENT]:\t",
+  // Source used for server to client messages.
+  server: "\t➡️ [RECEIVED]:\t",
+}
+
+export function getSourcePrefix(source: keyof typeof sourceEmojis) {
+  return sourceEmojis[source]
+}

+ 1 - 1
packages/hoppscotch-app/layouts/default.vue

@@ -24,7 +24,7 @@
             >
               <Pane class="flex flex-1 hide-scrollbar !overflow-auto">
                 <main class="flex flex-1 w-full">
-                  <nuxt class="flex flex-1" />
+                  <nuxt class="flex overflow-y-auto flex-1" />
                 </main>
               </Pane>
             </Splitpanes>

+ 1 - 0
packages/hoppscotch-app/locales/en.json

@@ -421,6 +421,7 @@
     "file_imported": "File imported",
     "finished_in": "Finished in {duration}ms",
     "history_deleted": "History deleted",
+    "linewrap": "Wrap lines",
     "loading": "Loading...",
     "none": "None",
     "nothing_found": "Nothing found for",

+ 134 - 0
packages/hoppscotch-app/modules/emit-volar-types.ts

@@ -0,0 +1,134 @@
+import { resolve } from "path"
+import { Module } from "@nuxt/types"
+import ts from "typescript"
+import chokidar from "chokidar"
+
+const { readdir, writeFile } = require("fs").promises
+
+function titleCase(str: string): string {
+  return str[0].toUpperCase() + str.substring(1)
+}
+
+async function* getFilesInDir(dir: string): AsyncIterable<string> {
+  const dirents = await readdir(dir, { withFileTypes: true })
+  for (const dirent of dirents) {
+    const res = resolve(dir, dirent.name)
+    if (dirent.isDirectory()) {
+      yield* getFilesInDir(res)
+    } else {
+      yield res
+    }
+  }
+}
+
+async function getAllVueComponentPaths(): Promise<string[]> {
+  const vueFilePaths: string[] = []
+
+  for await (const f of getFilesInDir("./components")) {
+    if (f.endsWith(".vue")) {
+      const componentsIndex = f.split("/").indexOf("components")
+
+      vueFilePaths.push(`./${f.split("/").slice(componentsIndex).join("/")}`)
+    }
+  }
+
+  return vueFilePaths
+}
+
+function resolveComponentName(filename: string): string {
+  const index = filename.split("/").indexOf("components")
+
+  return filename
+    .split("/")
+    .slice(index + 1)
+    .filter((x) => x !== "index.vue") // Remove index.vue
+    .map((x) => x.split(".vue")[0]) // Remove extension
+    .filter((x) => x.toUpperCase() !== x.toLowerCase()) // Remove non-word stuff
+    .map((x) => titleCase(x)) // titlecase it
+    .join("")
+}
+
+function createTSImports(components: [string, string][]) {
+  return components.map(([componentName, componentPath]) => {
+    return ts.factory.createImportDeclaration(
+      undefined,
+      undefined,
+      ts.factory.createImportClause(
+        false,
+        ts.factory.createIdentifier(componentName),
+        undefined
+      ),
+      ts.factory.createStringLiteral(componentPath)
+    )
+  })
+}
+
+function createTSProps(components: [string, string][]) {
+  return components.map(([componentName]) => {
+    return ts.factory.createPropertySignature(
+      undefined,
+      ts.factory.createIdentifier(componentName),
+      undefined,
+      ts.factory.createTypeQueryNode(ts.factory.createIdentifier(componentName))
+    )
+  })
+}
+
+function generateTypeScriptDef(components: [string, string][]) {
+  const statements = [
+    ...createTSImports(components),
+    ts.factory.createModuleDeclaration(
+      undefined,
+      [ts.factory.createModifier(ts.SyntaxKind.DeclareKeyword)],
+      ts.factory.createIdentifier("global"),
+      ts.factory.createModuleBlock([
+        ts.factory.createInterfaceDeclaration(
+          undefined,
+          undefined,
+          ts.factory.createIdentifier("__VLS_GlobalComponents"),
+          undefined,
+          undefined,
+          [...createTSProps(components)]
+        ),
+      ]),
+      ts.NodeFlags.ExportContext |
+        ts.NodeFlags.GlobalAugmentation |
+        ts.NodeFlags.ContextFlags
+    ),
+  ]
+
+  const source = ts.factory.createSourceFile(
+    statements,
+    ts.factory.createToken(ts.SyntaxKind.EndOfFileToken),
+    ts.NodeFlags.None
+  )
+
+  const printer = ts.createPrinter({
+    newLine: ts.NewLineKind.LineFeed,
+  })
+
+  return printer.printFile(source)
+}
+
+async function generateShim() {
+  const results = await getAllVueComponentPaths()
+  const fileComponentNameCombo: [string, string][] = results.map((x) => [
+    resolveComponentName(x),
+    x,
+  ])
+  const typescriptString = generateTypeScriptDef(fileComponentNameCombo)
+
+  await writeFile(resolve("shims-volar.d.ts"), typescriptString)
+}
+
+const module: Module<{}> = async function () {
+  if (!this.nuxt.options.dev) return
+
+  await generateShim()
+
+  chokidar.watch(resolve("../components/")).on("all", async () => {
+    await generateShim()
+  })
+}
+
+export default module

+ 2 - 1
packages/hoppscotch-app/nuxt.config.js

@@ -133,6 +133,7 @@ export default {
     "@nuxtjs/composition-api/module",
     // https://github.com/antfu/unplugin-vue2-script-setup
     "unplugin-vue2-script-setup/nuxt",
+    "~/modules/emit-volar-types.ts",
   ],
 
   // Modules (https://go.nuxtjs.dev/config-modules)
@@ -280,7 +281,7 @@ export default {
         config.module.rules.push({
           test: /\.js$/,
           include: /(node_modules)/,
-          exclude: /(node_modules)\/(ace-builds)|(@firebase)/,
+          exclude: /(node_modules)\/(@firebase)/,
           loader: "babel-loader",
           options: {
             plugins: [

+ 17 - 19
packages/hoppscotch-app/package.json

@@ -20,29 +20,26 @@
     "lintfix": "eslint --ext .ts,.js,.vue --ignore-path .gitignore . --fix",
     "test": "jest"
   },
-  "lint-staged": {
-    "*.{ts,js,vue}": "eslint",
-    "*.{css,scss,vue}": "stylelint"
-  },
   "dependencies": {
-    "@apollo/client": "^3.4.10",
+    "@apollo/client": "^3.4.11",
     "@nuxtjs/axios": "^5.13.6",
-    "@nuxtjs/composition-api": "^0.28.0",
+    "@nuxtjs/composition-api": "^0.29.0",
     "@nuxtjs/gtm": "^2.4.0",
     "@nuxtjs/i18n": "^7.0.3",
     "@nuxtjs/robots": "^2.5.0",
     "@nuxtjs/sitemap": "^2.4.0",
     "@nuxtjs/toast": "^3.3.1",
-    "ace-builds": "^1.4.12",
     "acorn": "^8.5.0",
     "acorn-walk": "^8.2.0",
-    "axios": "^0.21.4",
-    "core-js": "^3.17.2",
+    "codemirror": "^5.62.3",
+    "codemirror-theme-github": "^1.0.0",
+    "core-js": "^3.17.3",
     "esprima": "^4.0.1",
-    "firebase": "^9.0.1",
+    "firebase": "^9.0.2",
     "fuse.js": "^6.4.6",
     "graphql": "^15.5.0",
     "graphql-language-service-interface": "^2.8.4",
+    "graphql-language-service-parser": "^1.9.2",
     "json-loader": "^0.5.7",
     "lodash": "^4.17.21",
     "mustache": "^4.2.0",
@@ -65,7 +62,7 @@
   },
   "devDependencies": {
     "@babel/core": "^7.15.5",
-    "@babel/preset-env": "^7.15.4",
+    "@babel/preset-env": "^7.15.6",
     "@commitlint/cli": "^13.1.0",
     "@commitlint/config-conventional": "^13.1.0",
     "@nuxt/types": "^2.15.8",
@@ -79,33 +76,34 @@
     "@nuxtjs/stylelint-module": "^4.0.0",
     "@nuxtjs/svg": "^0.2.0",
     "@testing-library/jest-dom": "^5.14.1",
+    "@types/codemirror": "^5.60.2",
     "@types/cookie": "^0.4.1",
+    "@types/esprima": "^4.0.3",
     "@types/lodash": "^4.14.172",
     "@types/splitpanes": "^2.2.1",
-    "@vue/runtime-dom": "^3.2.10",
+    "@vue/runtime-dom": "^3.2.11",
     "@vue/test-utils": "^1.2.2",
     "babel-core": "^7.0.0-bridge.0",
-    "babel-jest": "^27.1.0",
+    "babel-jest": "^27.2.0",
     "eslint": "^7.32.0",
     "eslint-config-prettier": "^8.1.0",
     "eslint-plugin-nuxt": ">=2.0.0",
     "eslint-plugin-prettier": "^4.0.0",
     "eslint-plugin-vue": "^7.17.0",
-    "jest": "^27.1.0",
+    "jest": "^27.2.0",
     "jest-serializer-vue": "^2.0.2",
-    "lint-staged": "^11.1.2",
-    "nuxt-windicss": "^1.2.3",
-    "prettier": "^2.3.2",
+    "nuxt-windicss": "^1.2.4",
+    "prettier": "^2.4.0",
     "pretty-quick": "^3.1.1",
     "raw-loader": "^4.0.2",
-    "sass": "^1.39.0",
+    "sass": "^1.40.1",
     "sass-loader": "^10.2.0",
     "stylelint": "^13.12.0",
     "stylelint-config-prettier": "^8.0.2",
     "stylelint-config-standard": "^22.0.0",
     "ts-jest": "^27.0.5",
     "typescript": "^4.2",
-    "unplugin-vue2-script-setup": "^0.5.8",
+    "unplugin-vue2-script-setup": "^0.6.1",
     "vue-jest": "^3.0.7",
     "worker-loader": "^3.0.8"
   }

+ 5 - 10
packages/hoppscotch-app/pages/documentation.vue

@@ -61,17 +61,12 @@
                 @click.native="collectionJSON = '[]'"
               />
             </div>
-            <SmartAceEditor
+            <textarea-autosize
+              id="import-curl"
               v-model="collectionJSON"
-              :lang="'json'"
-              :lint="false"
-              :options="{
-                maxLines: Infinity,
-                minLines: 16,
-                autoScrollEditorIntoView: true,
-                showPrintMargin: false,
-                useWorker: false,
-              }"
+              class="font-mono p-4 bg-primary"
+              autofocus
+              rows="8"
             />
             <div
               class="

+ 25 - 31
packages/hoppscotch-app/pages/graphql.vue

@@ -1,36 +1,30 @@
 <template>
-  <div>
-    <Splitpanes
-      class="smart-splitter"
-      :dbl-click-splitter="false"
-      :horizontal="!(windowInnerWidth.x.value >= 768)"
+  <Splitpanes
+    class="smart-splitter"
+    :dbl-click-splitter="false"
+    :horizontal="!(windowInnerWidth.x.value >= 768)"
+  >
+    <Pane class="hide-scrollbar !overflow-auto">
+      <Splitpanes class="smart-splitter" :dbl-click-splitter="false" horizontal>
+        <Pane class="hide-scrollbar !overflow-auto">
+          <GraphqlRequest :conn="gqlConn" />
+          <GraphqlRequestOptions :conn="gqlConn" />
+        </Pane>
+        <Pane class="hide-scrollbar !overflow-auto">
+          <GraphqlResponse :conn="gqlConn" />
+        </Pane>
+      </Splitpanes>
+    </Pane>
+    <Pane
+      v-if="RIGHT_SIDEBAR"
+      max-size="35"
+      size="25"
+      min-size="20"
+      class="hide-scrollbar !overflow-auto"
     >
-      <Pane class="hide-scrollbar !overflow-auto">
-        <Splitpanes
-          class="smart-splitter"
-          :dbl-click-splitter="false"
-          horizontal
-        >
-          <Pane class="hide-scrollbar !overflow-auto">
-            <GraphqlRequest :conn="gqlConn" />
-            <GraphqlRequestOptions :conn="gqlConn" />
-          </Pane>
-          <Pane class="hide-scrollbar !overflow-auto">
-            <GraphqlResponse :conn="gqlConn" />
-          </Pane>
-        </Splitpanes>
-      </Pane>
-      <Pane
-        v-if="RIGHT_SIDEBAR"
-        max-size="35"
-        size="25"
-        min-size="20"
-        class="hide-scrollbar !overflow-auto"
-      >
-        <GraphqlSidebar :conn="gqlConn" />
-      </Pane>
-    </Splitpanes>
-  </div>
+      <GraphqlSidebar :conn="gqlConn" />
+    </Pane>
+  </Splitpanes>
 </template>
 
 <script lang="ts">

+ 1 - 1
packages/hoppscotch-app/pages/settings.vue

@@ -298,7 +298,7 @@
               </div>
             </div>
             <div class="flex space-x-2 py-4 items-center">
-              <div class="flex flex-1 items-center relative">
+              <div class="flex flex-1 flex-col relative">
                 <input
                   id="url"
                   v-model="PROXY_URL"

File diff suppressed because it is too large
+ 315 - 141
pnpm-lock.yaml


Some files were not shown because too many files changed in this diff