Просмотр исходного кода

feat: desktop app

Co-authored-by: Vivek R <123vivekr@gmail.com>
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
Andrew Bastin 1 год назад
Родитель
Сommit
16044b5840

+ 21 - 1
packages/hoppscotch-common/locales/en.json

@@ -1,5 +1,6 @@
 {
   "action": {
+    "add": "Add",
     "autoscroll": "Autoscroll",
     "cancel": "Cancel",
     "choose_file": "Choose a file",
@@ -54,9 +55,28 @@
     "new": "Add new",
     "star": "Add star"
   },
+  "cookies": {
+    "modal": {
+      "new_domain_name": "New domain name",
+      "set": "Set a cookie",
+      "cookie_string": "Cookie string",
+      "enter_cookie_string": "Enter cookie string",
+      "cookie_name": "Name",
+      "cookie_value": "Value",
+      "cookie_path": "Path",
+      "cookie_expires": "Expires",
+      "managed_tab": "Managed",
+      "raw_tab": "Raw",
+      "interceptor_no_support": "Your currently selected interceptor does not support cookies. Select a different Interceptor and try again.",
+      "empty_domains": "Domain list is empty",
+      "empty_domain": "Domain is empty",
+      "no_cookies_in_domain": "No cookies set for this domain"
+    }
+  },
   "app": {
     "chat_with_us": "Chat with us",
     "contact_us": "Contact us",
+    "cookies": "Cookies",
     "copy": "Copy",
     "copy_user_id": "Copy User Auth Token",
     "developer_option": "Developer options",
@@ -764,7 +784,7 @@
     "published_error": "Something went wrong while publishing msg: {topic} to topic: {message}",
     "published_message": "Published message: {message} to topic: {topic}",
     "reconnection_error": "Failed to reconnect",
-    "show":"Show",
+    "show": "Show",
     "subscribed_failed": "Failed to subscribe to topic: {topic}",
     "subscribed_success": "Successfully subscribed to topic: {topic}",
     "unsubscribed_failed": "Failed to unsubscribe from topic: {topic}",

+ 5 - 1
packages/hoppscotch-common/package.json

@@ -52,6 +52,7 @@
     "acorn-walk": "^8.2.0",
     "axios": "^1.4.0",
     "buffer": "^6.0.3",
+    "cookie-es": "^1.0.0",
     "dioc": "workspace:^",
     "esprima": "^4.0.1",
     "events": "^3.3.0",
@@ -76,6 +77,8 @@
     "process": "^0.11.10",
     "qs": "^6.11.2",
     "rxjs": "^7.8.1",
+    "set-cookie-parser": "^2.6.0",
+    "set-cookie-parser-es": "^1.0.5",
     "socket.io-client-v2": "npm:socket.io-client@^2.4.0",
     "socket.io-client-v3": "npm:socket.io-client@^3.1.3",
     "socket.io-client-v4": "npm:socket.io-client@^4.4.1",
@@ -98,7 +101,8 @@
     "wonka": "^6.3.4",
     "workbox-window": "^7.0.0",
     "xml-formatter": "^3.5.0",
-    "yargs-parser": "^21.1.1"
+    "yargs-parser": "^21.1.1",
+    "zod": "^3.22.2"
   },
   "devDependencies": {
     "@esbuild-plugins/node-globals-polyfill": "^0.2.3",

+ 8 - 11
packages/hoppscotch-common/src/components.d.ts

@@ -1,11 +1,11 @@
-/* eslint-disable */
-/* prettier-ignore */
-// @ts-nocheck
-// Generated by unplugin-vue-components
+// generated by unplugin-vue-components
+// We suggest you to commit this file into source control
 // Read more: https://github.com/vuejs/core/pull/3399
+import '@vue/runtime-core'
+
 export {}
 
-declare module 'vue' {
+declare module '@vue/runtime-core' {
   export interface GlobalComponents {
     AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default']
     AppAnnouncement: typeof import('./components/app/Announcement.vue')['default']
@@ -58,6 +58,8 @@ declare module 'vue' {
     CollectionsRequest: typeof import('./components/collections/Request.vue')['default']
     CollectionsSaveRequest: typeof import('./components/collections/SaveRequest.vue')['default']
     CollectionsTeamCollections: typeof import('./components/collections/TeamCollections.vue')['default']
+    CookiesAllModal: typeof import('./components/cookies/AllModal.vue')['default']
+    CookiesEditCookie: typeof import('./components/cookies/EditCookie.vue')['default']
     Environments: typeof import('./components/environments/index.vue')['default']
     EnvironmentsAdd: typeof import('./components/environments/Add.vue')['default']
     EnvironmentsImportExport: typeof import('./components/environments/ImportExport.vue')['default']
@@ -90,13 +92,11 @@ declare module 'vue' {
     HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary']
     HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary']
     HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor']
-    HoppSmartAutoComplete: typeof import('@hoppscotch/ui')['HoppSmartAutoComplete']
     HoppSmartCheckbox: typeof import('@hoppscotch/ui')['HoppSmartCheckbox']
     HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal']
     HoppSmartExpand: typeof import('@hoppscotch/ui')['HoppSmartExpand']
     HoppSmartFileChip: typeof import('@hoppscotch/ui')['HoppSmartFileChip']
     HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput']
-    HoppSmartIntersection: typeof import('@hoppscotch/ui')['HoppSmartIntersection']
     HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem']
     HoppSmartLink: typeof import('@hoppscotch/ui')['HoppSmartLink']
     HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal']
@@ -143,7 +143,6 @@ declare module 'vue' {
     IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default']
     IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
     IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default']
-    IconLucideBrush: typeof import('~icons/lucide/brush')['default']
     IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default']
     IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
     IconLucideGlobe: typeof import('~icons/lucide/globe')['default']
@@ -153,10 +152,8 @@ declare module 'vue' {
     IconLucideLayers: typeof import('~icons/lucide/layers')['default']
     IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
     IconLucideMinus: typeof import('~icons/lucide/minus')['default']
-    IconLucideRss: typeof import('~icons/lucide/rss')['default']
     IconLucideSearch: typeof import('~icons/lucide/search')['default']
     IconLucideUsers: typeof import('~icons/lucide/users')['default']
-    IconLucideVerified: typeof import('~icons/lucide/verified')['default']
     InterceptorsExtensionSubtitle: typeof import('./components/interceptors/ExtensionSubtitle.vue')['default']
     LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']
     LensesHeadersRendererEntry: typeof import('./components/lenses/HeadersRendererEntry.vue')['default']
@@ -189,7 +186,6 @@ declare module 'vue' {
     SmartEnvInput: typeof import('./components/smart/EnvInput.vue')['default']
     SmartExpand: typeof import('./../../hoppscotch-ui/src/components/smart/Expand.vue')['default']
     SmartFileChip: typeof import('./../../hoppscotch-ui/src/components/smart/FileChip.vue')['default']
-    SmartFontSizePicker: typeof import('./components/smart/FontSizePicker.vue')['default']
     SmartInput: typeof import('./../../hoppscotch-ui/src/components/smart/Input.vue')['default']
     SmartIntersection: typeof import('./../../hoppscotch-ui/src/components/smart/Intersection.vue')['default']
     SmartItem: typeof import('./../../hoppscotch-ui/src/components/smart/Item.vue')['default']
@@ -222,4 +218,5 @@ declare module 'vue' {
     WorkspaceCurrent: typeof import('./components/workspace/Current.vue')['default']
     WorkspaceSelector: typeof import('./components/workspace/Selector.vue')['default']
   }
+
 }

+ 13 - 0
packages/hoppscotch-common/src/components/app/Footer.vue

@@ -20,6 +20,12 @@
             <AppInterceptor />
           </template>
         </tippy>
+        <HoppButtonSecondary
+          v-if="platform.platformFeatureFlags.cookiesEnabled ?? false"
+          :label="t('app.cookies')"
+          :icon="IconCookie"
+          @click="showCookiesModal = true"
+        />
       </div>
       <div class="flex">
         <tippy
@@ -195,12 +201,17 @@
       :show="showDeveloperOptions"
       @hide-modal="showDeveloperOptions = false"
     />
+    <CookiesAllModal
+      :show="showCookiesModal"
+      @hide-modal="showCookiesModal = false"
+    />
   </div>
 </template>
 
 <script setup lang="ts">
 import { ref } from "vue"
 import { version } from "~/../package.json"
+import IconCookie from "~icons/lucide/cookie"
 import IconSidebar from "~icons/lucide/sidebar"
 import IconZap from "~icons/lucide/zap"
 import IconShare2 from "~icons/lucide/share-2"
@@ -223,7 +234,9 @@ import { invokeAction } from "@helpers/actions"
 import { HoppSmartItem } from "@hoppscotch/ui"
 
 const t = useI18n()
+
 const showDeveloperOptions = ref(false)
+const showCookiesModal = ref(false)
 
 const EXPAND_NAVIGATION = useSetting("EXPAND_NAVIGATION")
 const SIDEBAR = useSetting("SIDEBAR")

+ 24 - 16
packages/hoppscotch-common/src/components/collections/graphql/ImportExport.vue

@@ -258,7 +258,7 @@ const importFromJSON = () => {
   inputChooseFileToImportFrom.value.value = ""
 }
 
-const exportJSON = () => {
+const exportJSON = async () => {
   const dataToWrite = collectionJson.value
 
   const parsedCollections = JSON.parse(dataToWrite)
@@ -268,24 +268,32 @@ const exportJSON = () => {
   }
 
   const file = new Blob([dataToWrite], { type: "application/json" })
-  const a = document.createElement("a")
   const url = URL.createObjectURL(file)
-  a.href = url
 
-  platform?.analytics?.logEvent({
-    type: "HOPP_EXPORT_COLLECTION",
-    exporter: "json",
-    platform: "gql",
+  const filename = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
+
+  URL.revokeObjectURL(url)
+
+  const result = await platform.io.saveFileWithDialog({
+    data: dataToWrite,
+    contentType: "application/json",
+    suggestedFilename: filename,
+    filters: [
+      {
+        name: "Hoppscotch Collection JSON file",
+        extensions: ["json"],
+      },
+    ],
   })
 
-  // TODO: get uri from meta
-  a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
-  document.body.appendChild(a)
-  a.click()
-  toast.success(t("state.download_started").toString())
-  setTimeout(() => {
-    document.body.removeChild(a)
-    URL.revokeObjectURL(url)
-  }, 1000)
+  if (result.type === "unknown" || result.type === "saved") {
+    platform?.analytics?.logEvent({
+      type: "HOPP_EXPORT_COLLECTION",
+      exporter: "json",
+      platform: "gql",
+    })
+
+    toast.success(t("state.download_started").toString())
+  }
 }
 </script>

+ 19 - 19
packages/hoppscotch-common/src/components/collections/index.vue

@@ -1866,28 +1866,25 @@ const getJSONCollection = async () => {
  * @param collectionJSON - JSON string of the collection
  * @param name - Name of the collection set as the file name
  */
-const initializeDownloadCollection = (
+const initializeDownloadCollection = async (
   collectionJSON: string,
   name: string | null
 ) => {
-  const file = new Blob([collectionJSON], { type: "application/json" })
-  const a = document.createElement("a")
-  const url = URL.createObjectURL(file)
-  a.href = url
+  const result = await platform.io.saveFileWithDialog({
+    data: collectionJSON,
+    contentType: "application/json",
+    suggestedFilename: `${name ?? "collection"}.json`,
+    filters: [
+      {
+        name: "Hoppscotch Collection JSON file",
+        extensions: ["json"],
+      },
+    ],
+  })
 
-  if (name) {
-    a.download = `${name}.json`
-  } else {
-    a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
+  if (result.type === "unknown" || result.type === "saved") {
+    toast.success(t("state.download_started").toString())
   }
-
-  document.body.appendChild(a)
-  a.click()
-  toast.success(t("state.download_started").toString())
-  setTimeout(() => {
-    document.body.removeChild(a)
-    URL.revokeObjectURL(url)
-  }, 1000)
 }
 
 /**
@@ -1916,11 +1913,14 @@ const exportData = async (
           exportLoading.value = false
           return
         },
-        (coll) => {
+        async (coll) => {
           const hoppColl = teamCollToHoppRESTColl(coll)
           const collectionJSONString = JSON.stringify(hoppColl)
 
-          initializeDownloadCollection(collectionJSONString, hoppColl.name)
+          await initializeDownloadCollection(
+            collectionJSONString,
+            hoppColl.name
+          )
           exportLoading.value = false
         }
       )

+ 269 - 0
packages/hoppscotch-common/src/components/cookies/AllModal.vue

@@ -0,0 +1,269 @@
+<template>
+  <HoppSmartModal
+    v-if="show"
+    dialog
+    :title="t('app.cookies')"
+    aria-modal="true"
+    @close="hideModal"
+  >
+    <template #body>
+      <HoppSmartPlaceholder
+        v-if="!currentInterceptorSupportsCookies"
+        :text="t('cookies.modal.interceptor_no_support')"
+      >
+        <AppInterceptor class="p-2 border rounded border-dividerLight" />
+      </HoppSmartPlaceholder>
+      <div v-else class="flex flex-col">
+        <div
+          class="flex bg-primary space-x-2 border-b sticky border-dividerLight -mx-4 px-4 py-4 -mt-4"
+          style="top: calc(-1 * var(--line-height-body))"
+        >
+          <HoppSmartInput
+            v-model="newDomainText"
+            class="flex-1"
+            :placeholder="t('cookies.modal.new_domain_name')"
+            @keyup.enter="addNewDomain"
+          />
+          <HoppButtonSecondary
+            outline
+            filled
+            :label="t('action.add')"
+            @click="addNewDomain"
+          />
+        </div>
+        <div class="flex flex-col space-y-4">
+          <HoppSmartPlaceholder
+            v-if="workingCookieJar.size === 0"
+            :src="`/images/states/${colorMode.value}/blockchain.svg`"
+            :alt="`${t('cookies.modal.empty_domains')}`"
+            :text="t('cookies.modal.empty_domains')"
+            class="mt-6"
+          >
+          </HoppSmartPlaceholder>
+          <div
+            v-for="[domain, entries] in workingCookieJar.entries()"
+            v-else
+            :key="domain"
+            class="flex flex-col"
+          >
+            <div class="flex items-center justify-between flex-1">
+              <label for="cookiesList" class="p-4">
+                {{ domain }}
+              </label>
+              <div class="flex">
+                <HoppButtonSecondary
+                  v-tippy="{ theme: 'tooltip' }"
+                  :title="t('action.delete')"
+                  :icon="IconTrash2"
+                  @click="deleteDomain(domain)"
+                />
+                <HoppButtonSecondary
+                  v-tippy="{ theme: 'tooltip' }"
+                  :title="t('add.new')"
+                  :icon="IconPlus"
+                  @click="addCookieToDomain(domain)"
+                />
+              </div>
+            </div>
+            <div class="border rounded border-divider">
+              <div class="divide-y divide-dividerLight">
+                <div
+                  v-if="entries.length === 0"
+                  class="flex flex-col gap-2 p-4 items-center"
+                >
+                  {{ t("cookies.modal.no_cookies_in_domain") }}
+                </div>
+                <template v-else>
+                  <div
+                    v-for="(entry, entryIndex) in entries"
+                    :key="`${entry}-${entryIndex}`"
+                    class="flex divide-x divide-dividerLight"
+                  >
+                    <input
+                      class="flex flex-1 px-4 py-2 bg-transparent"
+                      :value="entry"
+                      readonly
+                    />
+                    <HoppButtonSecondary
+                      v-tippy="{ theme: 'tooltip' }"
+                      :title="t('action.edit')"
+                      :icon="IconEdit"
+                      @click="editCookie(domain, entryIndex, entry)"
+                    />
+                    <HoppButtonSecondary
+                      v-tippy="{ theme: 'tooltip' }"
+                      :title="t('action.remove')"
+                      :icon="IconTrash"
+                      color="red"
+                      @click="deleteCookie(domain, entryIndex)"
+                    />
+                  </div>
+                </template>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </template>
+    <template v-if="currentInterceptorSupportsCookies" #footer>
+      <span class="flex space-x-2">
+        <HoppButtonPrimary
+          v-focus
+          :label="t('action.save')"
+          outline
+          @click="saveCookieChanges"
+        />
+        <HoppButtonSecondary
+          :label="t('action.cancel')"
+          outline
+          filled
+          @click="cancelCookieChanges"
+        />
+      </span>
+      <HoppButtonSecondary
+        :label="t('action.clear_all')"
+        outline
+        filled
+        @click="clearAllDomains"
+      />
+    </template>
+  </HoppSmartModal>
+  <CookiesEditCookie
+    :show="!!showEditModalFor"
+    :entry="showEditModalFor"
+    @save-cookie="saveCookie"
+    @hide-modal="showEditModalFor = null"
+  />
+</template>
+
+<script setup lang="ts">
+import { useI18n } from "@composables/i18n"
+import { useService } from "dioc/vue"
+import { CookieJarService } from "~/services/cookie-jar.service"
+import IconTrash from "~icons/lucide/trash"
+import IconEdit from "~icons/lucide/edit"
+import IconTrash2 from "~icons/lucide/trash-2"
+import IconPlus from "~icons/lucide/plus"
+import { cloneDeep } from "lodash-es"
+import { ref, watch, computed } from "vue"
+import { InterceptorService } from "~/services/interceptor.service"
+import { EditCookieConfig } from "./EditCookie.vue"
+import { useColorMode } from "@composables/theming"
+import { useToast } from "@composables/toast"
+
+const props = defineProps<{
+  show: boolean
+}>()
+
+const emit = defineEmits<{
+  (e: "hide-modal"): void
+}>()
+
+const t = useI18n()
+const colorMode = useColorMode()
+const toast = useToast()
+
+const newDomainText = ref("")
+
+const interceptorService = useService(InterceptorService)
+const cookieJarService = useService(CookieJarService)
+
+const workingCookieJar = ref(cloneDeep(cookieJarService.cookieJar.value))
+
+const currentInterceptorSupportsCookies = computed(() => {
+  const currentInterceptor = interceptorService.currentInterceptor.value
+
+  if (!currentInterceptor) return true
+
+  return currentInterceptor.supportsCookies ?? false
+})
+
+function addNewDomain() {
+  if (newDomainText.value === "" || /^\s+$/.test(newDomainText.value)) {
+    toast.error(`${t("cookies.modal.empty_domain")}`)
+    return
+  }
+
+  workingCookieJar.value.set(newDomainText.value, [])
+  newDomainText.value = ""
+}
+
+function deleteDomain(domain: string) {
+  workingCookieJar.value.delete(domain)
+}
+
+function addCookieToDomain(domain: string) {
+  showEditModalFor.value = { type: "create", domain }
+}
+
+function clearAllDomains() {
+  workingCookieJar.value = new Map()
+  toast.success(`${t("state.cleared")}`)
+}
+
+watch(
+  () => props.show,
+  (show) => {
+    if (show) {
+      workingCookieJar.value = cloneDeep(cookieJarService.cookieJar.value)
+    }
+  }
+)
+
+const showEditModalFor = ref<EditCookieConfig | null>(null)
+
+function saveCookieChanges() {
+  cookieJarService.cookieJar.value = workingCookieJar.value
+  hideModal()
+}
+
+function cancelCookieChanges() {
+  hideModal()
+}
+
+function editCookie(domain: string, entryIndex: number, cookieEntry: string) {
+  showEditModalFor.value = {
+    type: "edit",
+    domain,
+    entryIndex,
+    currentCookieEntry: cookieEntry,
+  }
+}
+
+function deleteCookie(domain: string, entryIndex: number) {
+  const entry = workingCookieJar.value.get(domain)
+
+  if (entry) {
+    entry.splice(entryIndex, 1)
+  }
+}
+
+function saveCookie(cookie: string) {
+  if (showEditModalFor.value?.type === "create") {
+    const { domain } = showEditModalFor.value
+
+    const entry = workingCookieJar.value.get(domain)!
+    entry.push(cookie)
+
+    showEditModalFor.value = null
+
+    return
+  }
+
+  if (showEditModalFor.value?.type !== "edit") return
+
+  const { domain, entryIndex } = showEditModalFor.value!
+
+  const entry = workingCookieJar.value.get(domain)
+
+  if (entry) {
+    entry[entryIndex] = cookie
+  }
+
+  showEditModalFor.value = null
+}
+
+const hideModal = () => {
+  emit("hide-modal")
+}
+</script>

+ 195 - 0
packages/hoppscotch-common/src/components/cookies/EditCookie.vue

@@ -0,0 +1,195 @@
+<template>
+  <HoppSmartModal
+    v-if="show"
+    dialog
+    :title="t('cookies.modal.set')"
+    @close="hideModal"
+  >
+    <template #body>
+      <div class="border rounded border-dividerLight">
+        <div class="flex flex-col">
+          <div class="flex items-center justify-between pl-4">
+            <label class="font-semibold truncate text-secondaryLight">
+              {{ t("cookies.modal.cookie_string") }}
+            </label>
+            <div class="flex items-center">
+              <HoppButtonSecondary
+                v-tippy="{ theme: 'tooltip' }"
+                :title="t('action.clear_all')"
+                :icon="IconTrash2"
+                @click="clearContent()"
+              />
+              <HoppButtonSecondary
+                v-tippy="{ theme: 'tooltip' }"
+                :title="t('state.linewrap')"
+                :class="{ '!text-accent': linewrapEnabled }"
+                :icon="IconWrapText"
+                @click.prevent="linewrapEnabled = !linewrapEnabled"
+              />
+              <HoppButtonSecondary
+                v-tippy="{ theme: 'tooltip', allowHTML: true }"
+                :title="t('action.download_file')"
+                :icon="downloadIcon"
+                @click="downloadResponse"
+              />
+              <HoppButtonSecondary
+                v-tippy="{ theme: 'tooltip', allowHTML: true }"
+                :title="t('action.copy')"
+                :icon="copyIcon"
+                @click="copyResponse"
+              />
+            </div>
+          </div>
+          <div class="h-46">
+            <div
+              ref="cookieEditor"
+              class="h-full border-t rounded-b border-dividerLight"
+            ></div>
+          </div>
+        </div>
+      </div>
+    </template>
+    <template #footer>
+      <div class="flex space-x-2">
+        <HoppButtonPrimary
+          v-focus
+          :label="t('action.save')"
+          outline
+          @click="saveCookieChange"
+        />
+        <HoppButtonSecondary
+          :label="t('action.cancel')"
+          outline
+          filled
+          @click="cancelCookieChange"
+        />
+      </div>
+      <span class="flex">
+        <HoppButtonSecondary
+          :icon="pasteIcon"
+          :label="`${t('action.paste')}`"
+          filled
+          outline
+          @click="handlePaste"
+        />
+      </span>
+    </template>
+  </HoppSmartModal>
+</template>
+
+<script lang="ts">
+export type EditCookieConfig =
+  | { type: "create"; domain: string }
+  | {
+      type: "edit"
+      domain: string
+      entryIndex: number
+      currentCookieEntry: string
+    }
+</script>
+
+<script setup lang="ts">
+import { useI18n } from "@composables/i18n"
+import { useCodemirror } from "~/composables/codemirror"
+import { watch, ref, reactive } from "vue"
+import { refAutoReset } from "@vueuse/core"
+import IconWrapText from "~icons/lucide/wrap-text"
+import IconClipboard from "~icons/lucide/clipboard"
+import IconCheck from "~icons/lucide/check"
+import IconTrash2 from "~icons/lucide/trash-2"
+import { useToast } from "~/composables/toast"
+import {
+  useCopyResponse,
+  useDownloadResponse,
+} from "~/composables/lens-actions"
+
+// TODO: Build Managed Mode!
+
+const props = defineProps<{
+  show: boolean
+
+  entry: EditCookieConfig | null
+}>()
+
+const emit = defineEmits<{
+  (e: "save-cookie", cookie: string): void
+  (e: "hide-modal"): void
+}>()
+
+const t = useI18n()
+
+const toast = useToast()
+
+const cookieEditor = ref<HTMLElement>()
+const rawCookieString = ref("")
+const linewrapEnabled = ref(true)
+
+useCodemirror(
+  cookieEditor,
+  rawCookieString,
+  reactive({
+    extendedEditorConfig: {
+      mode: "text/plain",
+      placeholder: `${t("cookies.modal.enter_cookie_string")}`,
+      lineWrapping: linewrapEnabled,
+    },
+    linter: null,
+    completer: null,
+    environmentHighlights: false,
+  })
+)
+
+const pasteIcon = refAutoReset<typeof IconClipboard | typeof IconCheck>(
+  IconClipboard,
+  1000
+)
+
+watch(
+  () => props.entry,
+  () => {
+    if (!props.entry) return
+
+    if (props.entry.type === "create") {
+      rawCookieString.value = ""
+      return
+    }
+
+    rawCookieString.value = props.entry.currentCookieEntry
+  }
+)
+
+function hideModal() {
+  emit("hide-modal")
+}
+
+function cancelCookieChange() {
+  hideModal()
+}
+
+async function handlePaste() {
+  try {
+    const text = await navigator.clipboard.readText()
+    if (text) {
+      rawCookieString.value = text
+      pasteIcon.value = IconCheck
+    }
+  } catch (e) {
+    console.error("Failed to copy: ", e)
+    toast.error(t("profile.no_permission").toString())
+  }
+}
+
+function saveCookieChange() {
+  emit("save-cookie", rawCookieString.value)
+}
+
+const { copyIcon, copyResponse } = useCopyResponse(rawCookieString)
+const { downloadIcon, downloadResponse } = useDownloadResponse(
+  "",
+  rawCookieString
+)
+
+function clearContent() {
+  rawCookieString.value = ""
+}
+</script>

+ 21 - 13
packages/hoppscotch-common/src/components/environments/ImportExport.vue

@@ -375,7 +375,7 @@ const importFromPostman = ({
   importFromHoppscotch(environments)
 }
 
-const exportJSON = () => {
+const exportJSON = async () => {
   const dataToWrite = environmentJson.value
 
   const parsedCollections = JSON.parse(dataToWrite)
@@ -385,19 +385,27 @@ const exportJSON = () => {
   }
 
   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]}.json`
-  document.body.appendChild(a)
-  a.click()
-  toast.success(t("state.download_started").toString())
-  setTimeout(() => {
-    document.body.removeChild(a)
-    URL.revokeObjectURL(url)
-  }, 1000)
+
+  const filename = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
+
+  URL.revokeObjectURL(url)
+
+  const result = await platform.io.saveFileWithDialog({
+    data: dataToWrite,
+    contentType: "application/json",
+    suggestedFilename: filename,
+    filters: [
+      {
+        name: "JSON file",
+        extensions: ["json"],
+      },
+    ],
+  })
+
+  if (result.type === "unknown" || result.type === "saved") {
+    toast.success(t("state.download_started").toString())
+  }
 }
 
 const getErrorMessage = (err: GQLError<string>) => {

+ 23 - 12
packages/hoppscotch-common/src/components/graphql/Response.vue

@@ -59,6 +59,7 @@ import { useToast } from "@composables/toast"
 import { defineActionHandler } from "~/helpers/actions"
 import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
 import { GQLResponseEvent } from "~/helpers/graphql/connection"
+import { platform } from "~/platform"
 
 const t = useI18n()
 const toast = useToast()
@@ -111,21 +112,31 @@ const copyResponse = (str: string) => {
   toast.success(`${t("state.copied_to_clipboard")}`)
 }
 
-const downloadResponse = (str: string) => {
+const downloadResponse = async (str: string) => {
   const dataToWrite = str
   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 = IconCheck
-  toast.success(`${t("state.download_started")}`)
-  setTimeout(() => {
-    document.body.removeChild(a)
-    URL.revokeObjectURL(url)
-  }, 1000)
+
+  const filename = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
+
+  URL.revokeObjectURL(url)
+
+  const result = await platform.io.saveFileWithDialog({
+    data: dataToWrite,
+    contentType: "application/json",
+    suggestedFilename: filename,
+    filters: [
+      {
+        name: "JSON file",
+        extensions: ["json"],
+      },
+    ],
+  })
+
+  if (result.type === "unknown" || result.type === "saved") {
+    downloadResponseIcon.value = IconCheck
+    toast.success(`${t("state.download_started")}`)
+  }
 }
 
 defineActionHandler(

Некоторые файлы не были показаны из-за большого количества измененных файлов