feat: desktop app

Co-authored-by: Vivek R <123vivekr@gmail.com>
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
This commit is contained in:
Andrew Bastin
2023-11-07 14:01:00 +05:30
parent 4ebf850cb6
commit 16044b5840
134 changed files with 11814 additions and 206 deletions

View File

@@ -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")

View File

@@ -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>

View File

@@ -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
}
)

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>) => {

View File

@@ -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(

View File

@@ -202,6 +202,7 @@ import {
schemaString,
subscriptionFields,
} from "~/helpers/graphql/connection"
import { platform } from "~/platform"
type NavigationTabs = "history" | "collection" | "docs" | "schema"
type GqlTabs = "queries" | "mutations" | "subscriptions" | "types"
@@ -372,21 +373,33 @@ useCodemirror(
})
)
const downloadSchema = () => {
const dataToWrite = JSON.stringify(schemaString.value, null, 2)
const downloadSchema = async () => {
const dataToWrite = schemaString.value
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 = 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]
}.graphql`
URL.revokeObjectURL(url)
const result = await platform.io.saveFileWithDialog({
data: dataToWrite,
contentType: "application/graphql",
suggestedFilename: filename,
filters: [
{
name: "GraphQL Schema File",
extensions: ["graphql"],
},
],
})
if (result.type === "unknown" || result.type === "saved") {
downloadSchemaIcon.value = IconCheck
toast.success(`${t("state.download_started")}`)
}
}
const copySchema = () => {