feat: context menu (#3180)

Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
This commit is contained in:
Nivedin
2023-08-02 20:52:16 +05:30
committed by GitHub
parent d1a564d5b8
commit 8970ff5c68
22 changed files with 1447 additions and 77 deletions

View File

@@ -151,6 +151,11 @@
"save_unsaved_tab": "Do you want to save changes made in this tab?",
"sync": "Would you like to restore your workspace from cloud? This will discard your local progress."
},
"context_menu": {
"set_environment_variable": "Set as variable",
"add_parameter": "Add to parameter",
"open_link_in_new_tab": "Open link in new tab"
},
"count": {
"header": "Header {count}",
"message": "Message {count}",
@@ -195,16 +200,23 @@
"created": "Environment created",
"deleted": "Environment deletion",
"edit": "Edit Environment",
"global": "Global",
"invalid_name": "Please provide a name for the environment",
"my_environments": "My Environments",
"name": "Name",
"nested_overflow": "nested environment variables are limited to 10 levels",
"new": "New Environment",
"no_environment": "No environment",
"no_environment_description": "No environments were selected. Choose what to do with the following variables.",
"replace_with_variable": "Replace with variable",
"scope": "Scope",
"select": "Select environment",
"set_as_environment": "Set as environment",
"team_environments": "Team Environments",
"title": "Environments",
"updated": "Environment updated",
"value": "Value",
"variable": "Variable",
"variable_list": "Variable List"
},
"error": {

View File

@@ -110,7 +110,7 @@
"@graphql-codegen/typescript-urql-graphcache": "^2.3.1",
"@graphql-codegen/urql-introspection": "^2.2.0",
"@graphql-typed-document-node/core": "^3.1.1",
"@iconify-json/lucide": "^1.1.40",
"@iconify-json/lucide": "^1.1.109",
"@intlify/vite-plugin-vue-i18n": "^7.0.0",
"@relmify/jest-fp-ts": "^2.1.1",
"@rushstack/eslint-patch": "^1.1.4",

View File

@@ -9,6 +9,7 @@ declare module '@vue/runtime-core' {
export interface GlobalComponents {
AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default']
AppAnnouncement: typeof import('./components/app/Announcement.vue')['default']
AppContextMenu: typeof import('./components/app/ContextMenu.vue')['default']
AppDeveloperOptions: typeof import('./components/app/DeveloperOptions.vue')['default']
AppFooter: typeof import('./components/app/Footer.vue')['default']
AppGitHubStarButton: typeof import('./components/app/GitHubStarButton.vue')['default']
@@ -54,6 +55,7 @@ declare module '@vue/runtime-core' {
CollectionsSaveRequest: typeof import('./components/collections/SaveRequest.vue')['default']
CollectionsTeamCollections: typeof import('./components/collections/TeamCollections.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']
EnvironmentsMy: typeof import('./components/environments/my/index.vue')['default']
EnvironmentsMyDetails: typeof import('./components/environments/my/Details.vue')['default']
@@ -94,7 +96,6 @@ declare module '@vue/runtime-core' {
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab']
HoppSmartTabs: typeof import('@hoppscotch/ui')['HoppSmartTabs']
HoppSmartToggle: typeof import('@hoppscotch/ui')['HoppSmartToggle']
HoppSmartWindow: typeof import('@hoppscotch/ui')['HoppSmartWindow']
HoppSmartWindows: typeof import('@hoppscotch/ui')['HoppSmartWindows']
HttpAuthorization: typeof import('./components/http/Authorization.vue')['default']
@@ -133,10 +134,7 @@ declare module '@vue/runtime-core' {
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
<<<<<<< HEAD
IconLucideRss: typeof import('~icons/lucide/rss')['default']
=======
>>>>>>> 6db825779 (fix: firefox browser scrollbar issue)
IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideUsers: typeof import('~icons/lucide/users')['default']
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']

View File

@@ -0,0 +1,76 @@
<template>
<div
ref="contextMenuRef"
class="fixed bg-popover shadow-lg transform translate-y-8 border border-dividerDark p-2 rounded"
:style="`top: ${position.top}px; left: ${position.left}px; z-index: 1000;`"
>
<div v-if="contextMenuOptions" class="flex flex-col">
<div
v-for="option in contextMenuOptions"
:key="option.id"
class="flex flex-col space-y-2"
>
<HoppSmartItem
v-if="option.text.type === 'text' && option.text"
:icon="option.icon"
:label="option.text.text"
@click="handleClick(option)"
/>
<component
:is="option.text.component"
v-else-if="option.text.type === 'custom'"
/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { onClickOutside } from "@vueuse/core"
import { useService } from "dioc/vue"
import { ref, watch } from "vue"
import { ContextMenuResult, ContextMenuService } from "~/services/context-menu"
import { EnvironmentMenuService } from "~/services/context-menu/menu/environment.menu"
import { ParameterMenuService } from "~/services/context-menu/menu/parameter.menu"
import { URLMenuService } from "~/services/context-menu/menu/url.menu"
const props = defineProps<{
show: boolean
position: { top: number; left: number }
text: string | null
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const contextMenuRef = ref<any | null>(null)
const contextMenuOptions = ref<ContextMenuResult[]>([])
onClickOutside(contextMenuRef, () => {
emit("hide-modal")
})
const contextMenuService = useService(ContextMenuService)
useService(EnvironmentMenuService)
useService(ParameterMenuService)
useService(URLMenuService)
const handleClick = (option: { action: () => void }) => {
option.action()
emit("hide-modal")
}
watch(
() => [props.show, props.text],
(val) => {
if (val && props.text) {
const options = contextMenuService.getMenuFor(props.text)
contextMenuOptions.value = options
}
},
{ immediate: true }
)
</script>

View File

@@ -15,7 +15,10 @@
</div>
</div>
<div class="flex flex-col divide-y divide-dividerLight">
<HoppSmartPlaceholder v-if="isEmpty(shortcutsResults)">
<HoppSmartPlaceholder
v-if="isEmpty(shortcutsResults)"
:text="`${t('state.nothing_found')} ‟${filterText}”`"
>
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
</HoppSmartPlaceholder>
<details

View File

@@ -0,0 +1,208 @@
<template>
<HoppSmartModal
v-if="show"
:title="t('environment.set_as_environment')"
@close="hideModal"
>
<template #body>
<div class="flex space-y-4 flex-1 flex-col">
<div class="flex items-center space-x-8 ml-2">
<label for="name" class="font-semibold min-w-10">{{
t("environment.name")
}}</label>
<input
v-model="name"
type="text"
:placeholder="t('environment.variable')"
class="input"
/>
</div>
<div class="flex items-center space-x-8 ml-2">
<label for="value" class="font-semibold min-w-10">{{
t("environment.value")
}}</label>
<input type="text" :value="value" class="input" />
</div>
<div class="flex items-center space-x-8 ml-2">
<label for="scope" class="font-semibold min-w-10">
{{ t("environment.scope") }}
</label>
<div
class="relative flex flex-1 flex-col border border-divider rounded focus-visible:border-dividerDark"
>
<EnvironmentsSelector v-model="scope" :is-scope-selector="true" />
</div>
</div>
<div v-if="replaceWithVariable" class="flex space-x-2 mt-3">
<div class="min-w-18" />
<HoppSmartCheckbox
:on="replaceWithVariable"
title="t('environment.replace_with_variable'))"
@change="replaceWithVariable = !replaceWithVariable"
/>
<label for="replaceWithVariable">
{{ t("environment.replace_with_variable") }}</label
>
</div>
</div>
</template>
<template #footer>
<span class="flex space-x-2">
<HoppButtonPrimary
:label="t('action.save')"
outline
@click="addEnvironment"
/>
<HoppButtonSecondary
:label="t('action.cancel')"
outline
filled
@click="hideModal"
/>
</span>
</template>
</HoppSmartModal>
</template>
<script lang="ts" setup>
import { Environment } from "@hoppscotch/data"
import { ref, watch } from "vue"
import { useI18n } from "~/composables/i18n"
import { useToast } from "~/composables/toast"
import { GQLError } from "~/helpers/backend/GQLClient"
import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment"
import {
addEnvironmentVariable,
addGlobalEnvVariable,
} from "~/newstore/environments"
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { updateTeamEnvironment } from "~/helpers/backend/mutations/TeamEnvironment"
import { currentActiveTab } from "~/helpers/rest/tab"
const t = useI18n()
const toast = useToast()
const props = defineProps<{
show: boolean
position: { top: number; left: number }
name: string
value: string
replaceWithVariable: boolean
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const hideModal = () => {
emit("hide-modal")
}
watch(
() => props.show,
(newVal) => {
if (!newVal) {
scope.value = {
type: "global",
}
name.value = ""
replaceWithVariable.value = false
}
}
)
type Scope =
| {
type: "global"
}
| {
type: "my-environment"
environment: Environment
index: number
}
| {
type: "team-environment"
environment: TeamEnvironment
}
const scope = ref<Scope>({
type: "global",
})
const replaceWithVariable = ref(false)
const name = ref("")
const addEnvironment = async () => {
if (!name.value) {
toast.error(`${t("environment.invalid_name")}`)
return
}
if (scope.value.type === "global") {
addGlobalEnvVariable({
key: name.value,
value: props.value,
})
toast.success(`${t("environment.updated")}`)
} else if (scope.value.type === "my-environment") {
addEnvironmentVariable(scope.value.index, {
key: name.value,
value: props.value,
})
toast.success(`${t("environment.updated")}`)
} else {
const newVariables = [
...scope.value.environment.environment.variables,
{
key: name.value,
value: props.value,
},
]
await pipe(
updateTeamEnvironment(
JSON.stringify(newVariables),
scope.value.environment.id,
scope.value.environment.environment.name
),
TE.match(
(err: GQLError<string>) => {
console.error(err)
toast.error(`${getErrorMessage(err)}`)
},
() => {
hideModal()
toast.success(`${t("environment.updated")}`)
}
)
)()
}
if (replaceWithVariable.value) {
//replace the current tab endpoint with the variable name with << and >>
const variableName = `<<${name.value}>>`
//replace the currenttab endpoint containing the value in the text with variablename
currentActiveTab.value.document.request.endpoint =
currentActiveTab.value.document.request.endpoint.replace(
props.value,
variableName
)
}
hideModal()
}
const getErrorMessage = (err: GQLError<string>) => {
if (err.type === "network_error") {
return t("error.network_error")
} else {
switch (err.error) {
case "team_environment/not_found":
return t("team_environment.not_found")
case "Forbidden resource":
return t("profile.no_permission")
default:
return t("error.something_went_wrong")
}
}
}
</script>

View File

@@ -8,7 +8,7 @@
<span
v-tippy="{ theme: 'tooltip' }"
:title="`${t('environment.select')}`"
class="bg-transparent border-b border-dividerLight select-wrapper"
class="bg-transparent select-wrapper"
>
<HoppButtonSecondary
:icon="IconLayers"
@@ -22,6 +22,7 @@
class="flex-1 !justify-start pr-8 rounded-none"
/>
</span>
<template #content="{ hide }">
<div
ref="tippyActions"
@@ -31,6 +32,7 @@
@keyup.escape="hide()"
>
<HoppSmartItem
v-if="!isScopeSelector"
:label="`${t('environment.no_environment')}`"
:info-icon="
selectedEnvironmentIndex.type === 'NO_ENV_SELECTED'
@@ -47,6 +49,21 @@
}
"
/>
<HoppSmartItem
v-else-if="isScopeSelector && modelValue"
:label="t('environment.global')"
:icon="IconGlobe"
:info-icon="modelValue.type === 'global' ? IconCheck : undefined"
:active-info-icon="modelValue.type === 'global'"
@click="
() => {
$emit('update:modelValue', {
type: 'global',
})
hide()
}
"
/>
<HoppSmartTabs
v-model="selectedEnvTab"
styles="sticky overflow-x-auto my-2 border border-divider rounded flex-shrink-0 z-10 top-0 bg-primary"
@@ -61,11 +78,14 @@
:key="`gen-${index}`"
:icon="IconLayers"
:label="gen.name"
:info-icon="index === selectedEnv.index ? IconCheck : undefined"
:active-info-icon="index === selectedEnv.index"
:info-icon="isEnvActive(index) ? IconCheck : undefined"
:active-info-icon="isEnvActive(index)"
@click="
() => {
selectedEnvironmentIndex = { type: 'MY_ENV', index: index }
handleEnvironmentChange(index, {
type: 'my-environment',
environment: gen,
})
hide()
}
"
@@ -96,18 +116,14 @@
:key="`gen-team-${index}`"
:icon="IconLayers"
:label="gen.environment.name"
:info-icon="
gen.id === selectedEnv.teamEnvID ? IconCheck : undefined
"
:active-info-icon="gen.id === selectedEnv.teamEnvID"
:info-icon="isEnvActive(gen.id) ? IconCheck : undefined"
:active-info-icon="isEnvActive(gen.id)"
@click="
() => {
selectedEnvironmentIndex = {
type: 'TEAM_ENV',
teamEnvID: gen.id,
teamID: gen.teamID,
environment: gen.environment,
}
handleEnvironmentChange(index, {
type: 'team-environment',
environment: gen,
})
hide()
}
"
@@ -136,9 +152,10 @@
</template>
<script lang="ts" setup>
import { computed, ref, watch } from "vue"
import { computed, onMounted, ref, watch } from "vue"
import IconCheck from "~icons/lucide/check"
import IconLayers from "~icons/lucide/layers"
import IconGlobe from "~icons/lucide/globe"
import { TippyComponent } from "vue-tippy"
import { useI18n } from "~/composables/i18n"
import { GQLError } from "~/helpers/backend/GQLClient"
@@ -156,6 +173,31 @@ import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
import { useLocalState } from "~/newstore/localstate"
import { onLoggedIn } from "~/composables/auth"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment"
import { Environment } from "@hoppscotch/data"
type Scope =
| {
type: "global"
}
| {
type: "my-environment"
environment: Environment
index: number
}
| {
type: "team-environment"
environment: TeamEnvironment
}
const props = defineProps<{
isScopeSelector?: boolean
modelValue?: Scope
}>()
const emit = defineEmits<{
(e: "update:modelValue", data: Scope): void
}>()
const breakpoints = useBreakpoints(breakpointsTailwind)
const mdAndLarger = breakpoints.greater("md")
@@ -170,6 +212,39 @@ const myEnvironments = useReadonlyStream(environments$, [])
const workspace = useReadonlyStream(workspaceStatus$, { type: "personal" })
// TeamList-Adapter
const teamListAdapter = new TeamListAdapter(true)
const myTeams = useReadonlyStream(teamListAdapter.teamList$, null)
const teamListFetched = ref(false)
const REMEMBERED_TEAM_ID = useLocalState("REMEMBERED_TEAM_ID")
onLoggedIn(() => {
!teamListAdapter.isInitialized && teamListAdapter.initialize()
})
const switchToTeamWorkspace = (team: GetMyTeamsQuery["myTeams"][number]) => {
REMEMBERED_TEAM_ID.value = team.id
changeWorkspace({
teamID: team.id,
teamName: team.name,
type: "team",
})
}
watch(
() => myTeams.value,
(newTeams) => {
if (newTeams && !teamListFetched.value) {
teamListFetched.value = true
if (REMEMBERED_TEAM_ID.value) {
const team = newTeams.find((t) => t.id === REMEMBERED_TEAM_ID.value)
if (team) switchToTeamWorkspace(team)
}
}
}
)
// TeamEnv List Adapter
const teamEnvListAdapter = new TeamEnvironmentAdapter(undefined)
const teamListLoading = useReadonlyStream(teamEnvListAdapter.loading$, false)
const teamAdapterError = useReadonlyStream(teamEnvListAdapter.error$, null)
@@ -204,63 +279,152 @@ watch(
}
)
// TeamList-Adapter
const teamListAdapter = new TeamListAdapter(true)
const myTeams = useReadonlyStream(teamListAdapter.teamList$, null)
const teamListFetched = ref(false)
const REMEMBERED_TEAM_ID = useLocalState("REMEMBERED_TEAM_ID")
onLoggedIn(() => {
!teamListAdapter.isInitialized && teamListAdapter.initialize()
})
const switchToTeamWorkspace = (team: GetMyTeamsQuery["myTeams"][number]) => {
REMEMBERED_TEAM_ID.value = team.id
changeWorkspace({
teamID: team.id,
teamName: team.name,
type: "team",
})
}
watch(
() => myTeams.value,
(newTeams) => {
if (newTeams && !teamListFetched.value) {
teamListFetched.value = true
if (REMEMBERED_TEAM_ID.value) {
const team = newTeams.find((t) => t.id === REMEMBERED_TEAM_ID.value)
if (team) switchToTeamWorkspace(team)
const handleEnvironmentChange = (
index: number,
env?:
| {
type: "my-environment"
environment: Environment
}
| {
type: "team-environment"
environment: TeamEnvironment
}
) => {
if (props.isScopeSelector && env) {
if (env.type === "my-environment") {
emit("update:modelValue", {
type: "my-environment",
environment: env.environment,
index,
})
} else if (env.type === "team-environment") {
emit("update:modelValue", {
type: "team-environment",
environment: env.environment,
})
}
} else {
if (env && env.type === "my-environment") {
selectedEnvironmentIndex.value = {
type: "MY_ENV",
index,
}
} else if (env && env.type === "team-environment") {
selectedEnvironmentIndex.value = {
type: "TEAM_ENV",
teamEnvID: env.environment.id,
teamID: env.environment.teamID,
environment: env.environment.environment,
}
}
}
)
}
const isEnvActive = (id: string | number) => {
if (props.isScopeSelector) {
if (props.modelValue?.type === "my-environment") {
return props.modelValue.index === id
} else if (props.modelValue?.type === "team-environment") {
return (
props.modelValue?.type === "team-environment" &&
props.modelValue.environment &&
props.modelValue.environment.id === id
)
}
} else {
if (selectedEnvironmentIndex.value.type === "MY_ENV") {
return selectedEnv.value.index === id
} else {
return (
selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
selectedEnv.value.teamEnvID === id
)
}
}
}
const selectedEnv = computed(() => {
if (selectedEnvironmentIndex.value.type === "MY_ENV") {
return {
type: "MY_ENV",
index: selectedEnvironmentIndex.value.index,
name: myEnvironments.value[selectedEnvironmentIndex.value.index].name,
}
} else if (selectedEnvironmentIndex.value.type === "TEAM_ENV") {
const teamEnv = teamEnvironmentList.value.find(
(env) =>
env.id ===
(selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
selectedEnvironmentIndex.value.teamEnvID)
)
if (teamEnv) {
if (props.isScopeSelector) {
if (props.modelValue?.type === "my-environment") {
return {
type: "MY_ENV",
index: props.modelValue.index,
name: props.modelValue.environment?.name,
}
} else if (props.modelValue?.type === "team-environment") {
return {
type: "TEAM_ENV",
name: teamEnv.environment.name,
teamEnvID: selectedEnvironmentIndex.value.teamEnvID,
name: props.modelValue.environment.environment.name,
teamEnvID: props.modelValue.environment.id,
}
} else {
return { type: "global", name: "Global" }
}
} else {
if (selectedEnvironmentIndex.value.type === "MY_ENV") {
return {
type: "MY_ENV",
index: selectedEnvironmentIndex.value.index,
name: myEnvironments.value[selectedEnvironmentIndex.value.index].name,
}
} else if (selectedEnvironmentIndex.value.type === "TEAM_ENV") {
const teamEnv = teamEnvironmentList.value.find(
(env) =>
env.id ===
(selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
selectedEnvironmentIndex.value.teamEnvID)
)
if (teamEnv) {
return {
type: "TEAM_ENV",
name: teamEnv.environment.name,
teamEnvID: selectedEnvironmentIndex.value.teamEnvID,
}
} else {
return { type: "NO_ENV_SELECTED" }
}
} else {
return { type: "NO_ENV_SELECTED" }
}
} else {
return { type: "NO_ENV_SELECTED" }
}
})
// Set the selected environment as initial scope value
onMounted(() => {
if (props.isScopeSelector) {
if (
selectedEnvironmentIndex.value.type === "MY_ENV" &&
selectedEnvironmentIndex.value.index !== undefined
) {
emit("update:modelValue", {
type: "my-environment",
environment: myEnvironments.value[selectedEnvironmentIndex.value.index],
index: selectedEnvironmentIndex.value.index,
})
} else if (
selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
selectedEnvironmentIndex.value.teamEnvID &&
teamEnvironmentList.value &&
teamEnvironmentList.value.length > 0
) {
const teamEnv = teamEnvironmentList.value.find(
(env) =>
env.id ===
(selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
selectedEnvironmentIndex.value.teamEnvID)
)
if (teamEnv) {
emit("update:modelValue", {
type: "team-environment",
environment: teamEnv,
})
}
} else {
emit("update:modelValue", {
type: "global",
})
}
}
})

View File

@@ -26,6 +26,13 @@
:editing-variable-name="editingVariableName"
@hide-modal="displayModalEdit(false)"
/>
<EnvironmentsAdd
:show="showModalNew"
:name="editingVariableName"
:value="editingVariableValue"
:position="position"
@hide-modal="displayModalNew(false)"
/>
</div>
</template>
@@ -161,10 +168,18 @@ watch(
}
)
const showModalNew = ref(false)
const showModalDetails = ref(false)
const action = ref<"new" | "edit">("edit")
const editingEnvironmentIndex = ref<"Global" | null>(null)
const editingVariableName = ref("")
const editingVariableValue = ref("")
const position = ref({ top: 0, left: 0 })
const displayModalNew = (shouldDisplay: boolean) => {
showModalNew.value = shouldDisplay
}
const displayModalEdit = (shouldDisplay: boolean) => {
action.value = "edit"
@@ -233,4 +248,10 @@ watch(
},
{ deep: true }
)
defineActionHandler("modals.environment.add", ({ envName, variableName }) => {
editingVariableName.value = envName
editingVariableValue.value = variableName
displayModalNew(true)
})
</script>

View File

@@ -61,7 +61,8 @@ import { useReadonlyStream } from "@composables/stream"
import { AggregateEnvironment, aggregateEnvs$ } from "~/newstore/environments"
import { platform } from "~/platform"
import { useI18n } from "~/composables/i18n"
import { onClickOutside } from "@vueuse/core"
import { onClickOutside, useDebounceFn } from "@vueuse/core"
import { invokeAction } from "~/helpers/actions"
const props = withDefaults(
defineProps<{
@@ -149,6 +150,11 @@ const handleKeystroke = (ev: KeyboardEvent) => {
ev.preventDefault()
}
if (ev.shiftKey) {
showSuggestionPopover.value = false
return
}
showSuggestionPopover.value = true
if (
@@ -299,8 +305,46 @@ const envVars = computed(() =>
const envTooltipPlugin = new HoppReactiveEnvPlugin(envVars, view)
const initView = (el: any) => {
function handleTextSelection() {
const selection = view.value?.state.selection.main
if (selection) {
const from = selection.from
const to = selection.to
const text = view.value?.state.doc.sliceString(from, to)
const { top, left } = view.value?.coordsAtPos(from)
if (text) {
invokeAction("contextmenu.open", {
position: {
top,
left,
},
text,
})
showSuggestionPopover.value = false
} else {
invokeAction("contextmenu.open", {
position: {
top,
left,
},
text: null,
})
}
}
}
// Debounce to prevent double click from selecting the word
const debounceFn = useDebounceFn(() => {
handleTextSelection()
}, 140)
el.addEventListener("mouseup", debounceFn)
el.addEventListener("keyup", debounceFn)
const extensions: Extension = [
EditorView.lineWrapping,
EditorView.contentAttributes.of({ "aria-label": props.placeholder }),
EditorView.contentAttributes.of({ "data-enable-grammarly": "false" }),
EditorView.updateListener.of((update) => {
if (props.readonly) {
update.view.contentDOM.inputMode = "none"
@@ -431,7 +475,7 @@ watch(editor, () => {
@apply border-b border-x border-divider;
@apply overflow-y-auto;
@apply -left-[1px];
@apply right-0;
@apply -right-[1px];
top: calc(100% + 1px);
border-radius: 0 0 8px 8px;

View File

@@ -40,6 +40,8 @@ import {
import { HoppEnvironmentPlugin } from "@helpers/editor/extensions/HoppEnvironment"
import xmlFormat from "xml-formatter"
import { platform } from "~/platform"
import { invokeAction } from "~/helpers/actions"
import { useDebounceFn } from "@vueuse/core"
// TODO: Migrate from legacy mode
type ExtendedEditorConfig = {
@@ -218,6 +220,40 @@ export function useCodemirror(
ViewPlugin.fromClass(
class {
update(update: ViewUpdate) {
function handleTextSelection() {
const selection = view.value?.state.selection.main
if (selection) {
const from = selection.from
const to = selection.to
const text = view.value?.state.doc.sliceString(from, to)
const { top, left } = view.value?.coordsAtPos(from)
if (text) {
invokeAction("contextmenu.open", {
position: {
top,
left,
},
text,
})
} else {
invokeAction("contextmenu.open", {
position: {
top,
left,
},
text: null,
})
}
}
}
// Debounce to prevent double click from selecting the word
const debounceFn = useDebounceFn(() => {
handleTextSelection()
}, 140)
el.addEventListener("mouseup", debounceFn)
el.addEventListener("keyup", debounceFn)
const cursorPos = update.state.selection.main.head
const line = update.state.doc.lineAt(cursorPos)
@@ -276,6 +312,7 @@ export function useCodemirror(
run: indentLess,
},
]),
EditorView.contentAttributes.of({ "data-enable-grammarly": "false" }),
]
if (environmentTooltip) extensions.push(environmentTooltip.extension)

View File

@@ -8,6 +8,7 @@ import { HoppRESTDocument } from "./rest/document"
import { HoppGQLRequest } from "@hoppscotch/data"
export type HoppAction =
| "contextmenu.open" // Send/Cancel a Hoppscotch Request
| "request.send-cancel" // Send/Cancel a Hoppscotch Request
| "request.reset" // Clear request data
| "request.copy-link" // Copy Request Link
@@ -24,6 +25,9 @@ export type HoppAction =
| "modals.search.toggle" // Shows the search modal
| "modals.support.toggle" // Shows the support modal
| "modals.share.toggle" // Shows the share modal
| "modals.environment.add" // Show add environment modal via context menu
| "modals.my.environment.edit" // Edit current personal environment
| "modals.team.environment.edit" // Edit current team environment
| "navigation.jump.rest" // Jump to REST page
| "navigation.jump.graphql" // Jump to GraphQL page
| "navigation.jump.realtime" // Jump to realtime page
@@ -54,6 +58,13 @@ export type HoppAction =
* will know if you got something wrong if there is a type error in this file
*/
type HoppActionArgsMap = {
"contextmenu.open": {
position: {
top: number
left: number
}
text: string | null
}
"modals.my.environment.edit": {
envName: string
variableName: string
@@ -68,6 +79,10 @@ type HoppActionArgsMap = {
"gql.request.open": {
request: HoppGQLRequest
}
"modals.environment.add": {
envName: string
variableName: string
}
}
/**

View File

@@ -84,6 +84,13 @@
:show="savingRequest"
@hide-modal="onSaveModalClose"
/>
<AppContextMenu
v-if="contextMenu.show"
:show="contextMenu.show"
:position="contextMenu.position"
:text="contextMenu.text"
@hide-modal="contextMenu.show = false"
/>
</div>
</template>
@@ -138,6 +145,24 @@ const reqName = ref<string>("")
const t = useI18n()
const toast = useToast()
type PopupDetails = {
show: boolean
position: {
top: number
left: number
}
text: string | null
}
const contextMenu = ref<PopupDetails>({
show: false,
position: {
top: 0,
left: 0,
},
text: null,
})
const tabs = getActiveTabs()
const confirmSync = useReadonlyStream(currentSyncingStatus$, {
@@ -365,6 +390,22 @@ function oAuthURL() {
})
}
defineActionHandler("contextmenu.open", ({ position, text }) => {
if (text) {
contextMenu.value = {
show: true,
position,
text,
}
} else {
contextMenu.value = {
show: false,
position,
text,
}
}
})
setupTabStateSync()
bindRequestToURLParams()
oAuthURL()

View File

@@ -0,0 +1,114 @@
import { describe, it, expect, vi } from "vitest"
import { ContextMenu, ContextMenuResult, ContextMenuService } from "../"
import { TestContainer } from "dioc/testing"
const contextMenuResult: ContextMenuResult[] = [
{
id: "result1",
text: { type: "text", text: "Sample Text" },
icon: {},
// eslint-disable-next-line @typescript-eslint/no-empty-function
action: () => {},
},
]
const testMenu: ContextMenu = {
menuID: "menu1",
getMenuFor: () => {
return {
results: contextMenuResult,
}
},
}
describe("ContextMenuService", () => {
describe("registerMenu", () => {
it("should register a menu", () => {
const container = new TestContainer()
const service = container.bind(ContextMenuService)
service.registerMenu(testMenu)
const result = service.getMenuFor("text")
expect(result).toContainEqual(expect.objectContaining({ id: "result1" }))
})
it("should not register a menu twice", () => {
const container = new TestContainer()
const service = container.bind(ContextMenuService)
service.registerMenu(testMenu)
service.registerMenu(testMenu)
const result = service.getMenuFor("text")
expect(result).toHaveLength(1)
})
it("should register multiple menus", () => {
const container = new TestContainer()
const service = container.bind(ContextMenuService)
const testMenu2: ContextMenu = {
menuID: "menu2",
getMenuFor: () => {
return {
results: contextMenuResult,
}
},
}
service.registerMenu(testMenu)
service.registerMenu(testMenu2)
const result = service.getMenuFor("text")
expect(result).toHaveLength(2)
})
})
describe("getMenuFor", () => {
it("should get the menu", () => {
const sampleMenus = {
results: contextMenuResult,
}
const container = new TestContainer()
const service = container.bind(ContextMenuService)
service.registerMenu(testMenu)
const results = service.getMenuFor("sometext")
expect(results).toEqual(sampleMenus.results)
})
it("calls registered menus with correct value", () => {
const container = new TestContainer()
const service = container.bind(ContextMenuService)
const testMenu2: ContextMenu = {
menuID: "some-id",
getMenuFor: vi.fn(() => ({
results: contextMenuResult,
})),
}
service.registerMenu(testMenu2)
service.getMenuFor("sometext")
expect(testMenu2.getMenuFor).toHaveBeenCalledWith("sometext")
})
it("should return empty array if no menus are registered", () => {
const container = new TestContainer()
const service = container.bind(ContextMenuService)
const results = service.getMenuFor("sometext")
expect(results).toEqual([])
})
})
})

View File

@@ -0,0 +1,109 @@
import { Service } from "dioc"
import { Component } from "vue"
/**
* Defines how to render the text in a Context Menu Search Result
*/
export type ContextMenuTextType<T extends object | Component = never> =
| {
type: "text"
text: string
}
| {
type: "custom"
/**
* The component to render in place of the text
*/
component: T
/**
* The props to pass to the component
*/
componentProps: T extends Component<infer Props> ? Props : never
}
/**
* Defines info about a context menu result so the UI can render it
*/
export interface ContextMenuResult {
/**
* The unique ID of the result
*/
id: string
/**
* The text to render in the result
*/
text: ContextMenuTextType<any>
/**
* The icon to render as the signifier of the result
*/
icon: object | Component
/**
* The action to perform when the result is selected
*/
action: () => void
/**
* Additional metadata about the result
*/
meta?: {
/**
* The keyboard shortcut to trigger the result
*/
keyboardShortcut?: string[]
}
}
/**
* Defines the state of a context menu
*/
export type ContextMenuState = {
results: ContextMenuResult[]
}
/**
* Defines a context menu
*/
export interface ContextMenu {
/**
* The unique ID of the context menu
* This is used to identify the context menu
*/
menuID: string
/**
* Gets the context menu for the given text
* @param text The text to get the context menu for
* @returns The context menu state
*/
getMenuFor: (text: string) => ContextMenuState
}
/**
* Defines the context menu service
* This service is used to register context menus and get context menus for text
* This service is used by the context menu UI
*/
export class ContextMenuService extends Service {
public static readonly ID = "CONTEXT_MENU_SERVICE"
private menus: Map<string, ContextMenu> = new Map()
/**
* Registers a menu with the context menu service
* @param menu The menu to register
*/
public registerMenu(menu: ContextMenu) {
this.menus.set(menu.menuID, menu)
}
/**
* Gets the context menu for the given text
* @param text The text to get the context menu for
*/
public getMenuFor(text: string): ContextMenuResult[] {
const menus = Array.from(this.menus.values()).map((x) => x.getMenuFor(text))
const result = menus.flatMap((x) => x.results)
return result
}
}

View File

@@ -0,0 +1,70 @@
import { TestContainer } from "dioc/testing"
import { describe, expect, it, vi } from "vitest"
import { EnvironmentMenuService } from "../environment.menu"
import { ContextMenuService } from "../.."
vi.mock("~/modules/i18n", () => ({
__esModule: true,
getI18n: () => (x: string) => x,
}))
const actionsMock = vi.hoisted(() => ({
invokeAction: vi.fn(),
}))
vi.mock("~/helpers/actions", async () => {
return {
__esModule: true,
invokeAction: actionsMock.invokeAction,
}
})
describe("EnvironmentMenuService", () => {
it("registers with the contextmenu service upon initialization", () => {
const container = new TestContainer()
const registerContextMenuFn = vi.fn()
container.bindMock(ContextMenuService, {
registerMenu: registerContextMenuFn,
})
const environment = container.bind(EnvironmentMenuService)
expect(registerContextMenuFn).toHaveBeenCalledOnce()
expect(registerContextMenuFn).toHaveBeenCalledWith(environment)
})
describe("getMenuFor", () => {
it("should return a menu for adding environment", () => {
const container = new TestContainer()
const environment = container.bind(EnvironmentMenuService)
const test = "some-text"
const result = environment.getMenuFor(test)
expect(result.results).toContainEqual(
expect.objectContaining({ id: "environment" })
)
})
it("should invoke the add environment modal", () => {
const container = new TestContainer()
const environment = container.bind(EnvironmentMenuService)
const test = "some-text"
const result = environment.getMenuFor(test)
const action = result.results[0].action
action()
expect(actionsMock.invokeAction).toHaveBeenCalledOnce()
expect(actionsMock.invokeAction).toHaveBeenCalledWith(
"modals.environment.add",
{
envName: "test",
variableName: test,
}
)
})
})
})

View File

@@ -0,0 +1,94 @@
import { TestContainer } from "dioc/testing"
import { describe, expect, it, vi } from "vitest"
import { ContextMenuService } from "../.."
import { ParameterMenuService } from "../parameter.menu"
//regex containing both url and parameter
const urlAndParameterRegex = new RegExp("[^&?]*?=[^&?]*")
vi.mock("~/modules/i18n", () => ({
__esModule: true,
getI18n: () => (x: string) => x,
}))
const tabMock = vi.hoisted(() => ({
currentActiveTab: vi.fn(),
}))
vi.mock("~/helpers/rest/tab", () => ({
__esModule: true,
currentActiveTab: tabMock.currentActiveTab,
}))
describe("ParameterMenuService", () => {
it("registers with the contextmenu service upon initialization", () => {
const container = new TestContainer()
const registerContextMenuFn = vi.fn()
container.bindMock(ContextMenuService, {
registerMenu: registerContextMenuFn,
})
const parameter = container.bind(ParameterMenuService)
expect(registerContextMenuFn).toHaveBeenCalledOnce()
expect(registerContextMenuFn).toHaveBeenCalledWith(parameter)
describe("getMenuFor", () => {
it("validating if the text passes the regex and return the menu", () => {
const container = new TestContainer()
const parameter = container.bind(ParameterMenuService)
const test = "https://hoppscotch.io?id=some-text"
const result = parameter.getMenuFor(test)
if (test.match(urlAndParameterRegex)) {
expect(result.results).toContainEqual(
expect.objectContaining({ id: "parameter" })
)
} else {
expect(result.results).not.toContainEqual(
expect.objectContaining({ id: "parameter" })
)
}
})
it("should call the addParameter function when action is called", () => {
const addParameterFn = vi.fn()
const container = new TestContainer()
const environment = container.bind(ParameterMenuService)
const test = "https://hoppscotch.io"
const result = environment.getMenuFor(test)
const action = result.results[0].action
action()
expect(addParameterFn).toHaveBeenCalledOnce()
expect(addParameterFn).toHaveBeenCalledWith(action)
})
it("should call the extractParams function when addParameter function is called", () => {
const extractParamsFn = vi.fn()
const container = new TestContainer()
const environment = container.bind(ParameterMenuService)
const test = "https://hoppscotch.io"
const result = environment.getMenuFor(test)
const action = result.results[0].action
action()
expect(extractParamsFn).toHaveBeenCalledOnce()
expect(extractParamsFn).toHaveBeenCalledWith(action)
})
})
})
})

View File

@@ -0,0 +1,86 @@
import { TestContainer } from "dioc/testing"
import { describe, expect, it, vi } from "vitest"
import { ContextMenuService } from "../.."
import { URLMenuService } from "../url.menu"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
vi.mock("~/modules/i18n", () => ({
__esModule: true,
getI18n: () => (x: string) => x,
}))
const tabMock = vi.hoisted(() => ({
createNewTab: vi.fn(),
}))
vi.mock("~/helpers/rest/tab", () => ({
__esModule: true,
createNewTab: tabMock.createNewTab,
}))
describe("URLMenuService", () => {
it("registers with the contextmenu service upon initialization", () => {
const container = new TestContainer()
const registerContextMenuFn = vi.fn()
container.bindMock(ContextMenuService, {
registerMenu: registerContextMenuFn,
})
const environment = container.bind(URLMenuService)
expect(registerContextMenuFn).toHaveBeenCalledOnce()
expect(registerContextMenuFn).toHaveBeenCalledWith(environment)
})
describe("getMenuFor", () => {
it("validating if the text passes the regex and return the menu", () => {
function isValidURL(url: string) {
try {
new URL(url)
return true
} catch (error) {
// Fallback to regular expression check
const pattern = /^(https?:\/\/)?([\w.-]+)(\.[\w.-]+)+([/?].*)?$/
return pattern.test(url)
}
}
const container = new TestContainer()
const url = container.bind(URLMenuService)
const test = ""
const result = url.getMenuFor(test)
if (isValidURL(test)) {
expect(result.results).toContainEqual(
expect.objectContaining({ id: "link-tab" })
)
} else {
expect(result).toEqual({ results: [] })
}
})
it("should call the openNewTab function when action is called and a new hoppscotch tab is opened", () => {
const container = new TestContainer()
const url = container.bind(URLMenuService)
const test = "https://hoppscotch.io"
const result = url.getMenuFor(test)
result.results[0].action()
const request = {
...getDefaultRESTRequest(),
endpoint: test,
}
expect(tabMock.createNewTab).toHaveBeenCalledOnce()
expect(tabMock.createNewTab).toHaveBeenCalledWith({
request: request,
isDirty: false,
})
})
})
})

View File

@@ -0,0 +1,56 @@
import { Service } from "dioc"
import {
ContextMenu,
ContextMenuResult,
ContextMenuService,
ContextMenuState,
} from "../"
import { markRaw, ref } from "vue"
import { invokeAction } from "~/helpers/actions"
import IconPlusCircle from "~icons/lucide/plus-circle"
import { getI18n } from "~/modules/i18n"
/**
* This menu returns a single result that allows the user
* to add the selected text as an environment variable
* This menus is shown on all text selections
*/
export class EnvironmentMenuService extends Service implements ContextMenu {
public static readonly ID = "ENVIRONMENT_CONTEXT_MENU_SERVICE"
private t = getI18n()
public readonly menuID = "environment"
private readonly contextMenu = this.bind(ContextMenuService)
constructor() {
super()
this.contextMenu.registerMenu(this)
}
getMenuFor(text: Readonly<string>): ContextMenuState {
const results = ref<ContextMenuResult[]>([])
results.value = [
{
id: "environment",
text: {
type: "text",
text: this.t("context_menu.set_environment_variable"),
},
icon: markRaw(IconPlusCircle),
action: () => {
invokeAction("modals.environment.add", {
envName: "test",
variableName: text,
})
},
},
]
const resultObj = <ContextMenuState>{
results: results.value,
}
return resultObj
}
}

View File

@@ -0,0 +1,133 @@
import { Service } from "dioc"
import {
ContextMenu,
ContextMenuResult,
ContextMenuService,
ContextMenuState,
} from "../"
import { markRaw, ref } from "vue"
import IconArrowDownRight from "~icons/lucide/arrow-down-right"
import { currentActiveTab } from "~/helpers/rest/tab"
import { getI18n } from "~/modules/i18n"
//regex containing both url and parameter
const urlAndParameterRegex = new RegExp("[^&?]*?=[^&?]*")
interface Param {
[key: string]: string
}
/**
* The extracted parameters from the input
* with the new URL if it was provided
*/
interface ExtractedParams {
params: Param
newURL?: string
}
/**
* This menu returns a single result that allows the user
* to add the selected text as a parameter
* if the selected text is a valid URL
*/
export class ParameterMenuService extends Service implements ContextMenu {
public static readonly ID = "PARAMETER_CONTEXT_MENU_SERVICE"
private t = getI18n()
public readonly menuID = "parameter"
private readonly contextMenu = this.bind(ContextMenuService)
constructor() {
super()
this.contextMenu.registerMenu(this)
}
/**
*
* @param input The input to extract the parameters from
* @returns The extracted parameters and the new URL if it was provided
*/
private extractParams(input: string): ExtractedParams {
let text = input
let newURL: string | undefined
// if the input is a URL, extract the parameters
if (text.startsWith("http")) {
const url = new URL(text)
newURL = url.origin + url.pathname
text = url.search.slice(1)
}
const regex = /(\w+)=(\w+)/g
const matches = text.matchAll(regex)
const params: Param = {}
// extract the parameters from the input
for (const match of matches) {
const [, key, value] = match
params[key] = value
}
return { params, newURL }
}
/**
* Adds the parameters from the input to the current request
* parameters and updates the endpoint if a new URL was provided
* @param text The input to extract the parameters from
*/
private addParameter(text: string) {
const { params, newURL } = this.extractParams(text)
const queryParams = []
for (const [key, value] of Object.entries(params)) {
queryParams.push({ key, value, active: true })
}
// add the parameters to the current request parameters
currentActiveTab.value.document.request.params = [
...currentActiveTab.value.document.request.params,
...queryParams,
]
if (newURL) {
currentActiveTab.value.document.request.endpoint = newURL
} else {
// remove the parameter from the URL
const textRegex = new RegExp(`\\b${text.replace(/\?/g, "")}\\b`, "gi")
const sanitizedWord = currentActiveTab.value.document.request.endpoint
const newURL = sanitizedWord.replace(textRegex, "")
currentActiveTab.value.document.request.endpoint = newURL
}
}
getMenuFor(text: Readonly<string>): ContextMenuState {
const results = ref<ContextMenuResult[]>([])
if (urlAndParameterRegex.test(text)) {
results.value = [
{
id: "environment",
text: {
type: "text",
text: this.t("context_menu.add_parameter"),
},
icon: markRaw(IconArrowDownRight),
action: () => {
this.addParameter(text)
},
},
]
}
const resultObj = <ContextMenuState>{
results: results.value,
}
return resultObj
}
}

View File

@@ -0,0 +1,89 @@
import { Service } from "dioc"
import {
ContextMenu,
ContextMenuResult,
ContextMenuService,
ContextMenuState,
} from ".."
import { markRaw, ref } from "vue"
import IconCopyPlus from "~icons/lucide/copy-plus"
import { createNewTab } from "~/helpers/rest/tab"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { getI18n } from "~/modules/i18n"
/**
* Used to check if a string is a valid URL
* @param url The string to check
* @returns Whether the string is a valid URL
*/
function isValidURL(url: string) {
try {
// Try to create a URL object
// this will fail for endpoints like "localhost:3000", ie without a protocol
new URL(url)
return true
} catch (error) {
// Fallback to regular expression check
const pattern = /^(https?:\/\/)?([\w.-]+)(\.[\w.-]+)+([/?].*)?$/
return pattern.test(url)
}
}
export class URLMenuService extends Service implements ContextMenu {
public static readonly ID = "URL_CONTEXT_MENU_SERVICE"
private t = getI18n()
public readonly menuID = "url"
private readonly contextMenu = this.bind(ContextMenuService)
constructor() {
super()
this.contextMenu.registerMenu(this)
}
/**
* Opens a new tab with the provided URL
* @param url The URL to open
*/
private openNewTab(url: string) {
//create a new request object
const request = {
...getDefaultRESTRequest(),
endpoint: url,
}
createNewTab({
request: request,
isDirty: false,
})
}
getMenuFor(text: Readonly<string>): ContextMenuState {
const results = ref<ContextMenuResult[]>([])
if (isValidURL(text)) {
results.value = [
{
id: "link-tab",
text: {
type: "text",
text: this.t("context_menu.open_link_in_new_tab"),
},
icon: markRaw(IconCopyPlus),
action: () => {
this.openNewTab(text)
},
},
]
}
const resultObj = <ContextMenuState>{
results: results.value,
}
return resultObj
}
}

View File

@@ -47,7 +47,7 @@
"@esbuild-plugins/node-globals-polyfill": "^0.1.1",
"@esbuild-plugins/node-modules-polyfill": "^0.1.4",
"@histoire/plugin-vue": "^0.12.4",
"@iconify-json/lucide": "^1.1.40",
"@iconify-json/lucide": "^1.1.109",
"@intlify/vite-plugin-vue-i18n": "^6.0.1",
"@rushstack/eslint-patch": "^1.1.4",
"@types/lodash-es": "^4.17.6",

14
pnpm-lock.yaml generated
View File

@@ -638,8 +638,8 @@ importers:
specifier: ^3.1.1
version: 3.1.1(graphql@15.8.0)
'@iconify-json/lucide':
specifier: ^1.1.40
version: 1.1.40
specifier: ^1.1.109
version: 1.1.109
'@intlify/vite-plugin-vue-i18n':
specifier: ^7.0.0
version: 7.0.0(vite@3.1.4)(vue-i18n@9.2.2)
@@ -1240,8 +1240,8 @@ importers:
specifier: ^0.12.4
version: 0.12.4(histoire@0.12.4)(vite@3.2.4)(vue@3.2.45)
'@iconify-json/lucide':
specifier: ^1.1.40
version: 1.1.40
specifier: ^1.1.109
version: 1.1.109
'@intlify/vite-plugin-vue-i18n':
specifier: ^6.0.1
version: 6.0.1(vite@3.2.4)
@@ -5809,10 +5809,10 @@ packages:
/@iarna/toml@2.2.5:
resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==}
/@iconify-json/lucide@1.1.40:
resolution: {integrity: sha512-4GeQtaiv3mJ+b0sn/c2KL8Tgf4XQvsX1AHDOseuGRhgoLCWG+ZdNRFxF5sp1I6T/VcQccegLPOp5XHn3NC1mmA==}
/@iconify-json/lucide@1.1.109:
resolution: {integrity: sha512-1+zYieiKUAjN1x66kvcRmmtgBJaDbD7i4To8mhB6+3bEm/i61un76nspJ45LOSGovzBMvYZFIJpqJrGMipWPzw==}
dependencies:
'@iconify/types': 1.1.0
'@iconify/types': 2.0.0
dev: true
/@iconify/types@1.1.0: