diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json
index 6a84716dc..cb30e4ebe 100644
--- a/packages/hoppscotch-common/locales/en.json
+++ b/packages/hoppscotch-common/locales/en.json
@@ -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": {
diff --git a/packages/hoppscotch-common/package.json b/packages/hoppscotch-common/package.json
index fa8c73d27..ab8d2ad12 100644
--- a/packages/hoppscotch-common/package.json
+++ b/packages/hoppscotch-common/package.json
@@ -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",
diff --git a/packages/hoppscotch-common/src/components.d.ts b/packages/hoppscotch-common/src/components.d.ts
index 914ec6108..849ec7515 100644
--- a/packages/hoppscotch-common/src/components.d.ts
+++ b/packages/hoppscotch-common/src/components.d.ts
@@ -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']
diff --git a/packages/hoppscotch-common/src/components/app/ContextMenu.vue b/packages/hoppscotch-common/src/components/app/ContextMenu.vue
new file mode 100644
index 000000000..123d09202
--- /dev/null
+++ b/packages/hoppscotch-common/src/components/app/ContextMenu.vue
@@ -0,0 +1,76 @@
+
+
+
+
+
diff --git a/packages/hoppscotch-common/src/components/app/Shortcuts.vue b/packages/hoppscotch-common/src/components/app/Shortcuts.vue
index dc5d0e608..ec73ed19c 100644
--- a/packages/hoppscotch-common/src/components/app/Shortcuts.vue
+++ b/packages/hoppscotch-common/src/components/app/Shortcuts.vue
@@ -15,7 +15,10 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/hoppscotch-common/src/components/environments/Selector.vue b/packages/hoppscotch-common/src/components/environments/Selector.vue
index e960cf41a..4e25c7195 100644
--- a/packages/hoppscotch-common/src/components/environments/Selector.vue
+++ b/packages/hoppscotch-common/src/components/environments/Selector.vue
@@ -8,7 +8,7 @@
+
+
{
+ $emit('update:modelValue', {
+ type: 'global',
+ })
+ hide()
+ }
+ "
+ />
{
- 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 @@
diff --git a/packages/hoppscotch-common/src/components/smart/EnvInput.vue b/packages/hoppscotch-common/src/components/smart/EnvInput.vue
index 1f6deae37..86d6b38de 100644
--- a/packages/hoppscotch-common/src/components/smart/EnvInput.vue
+++ b/packages/hoppscotch-common/src/components/smart/EnvInput.vue
@@ -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;
diff --git a/packages/hoppscotch-common/src/composables/codemirror.ts b/packages/hoppscotch-common/src/composables/codemirror.ts
index 98d027873..500974da6 100644
--- a/packages/hoppscotch-common/src/composables/codemirror.ts
+++ b/packages/hoppscotch-common/src/composables/codemirror.ts
@@ -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)
diff --git a/packages/hoppscotch-common/src/helpers/actions.ts b/packages/hoppscotch-common/src/helpers/actions.ts
index 8d0d2e348..00ec882e4 100644
--- a/packages/hoppscotch-common/src/helpers/actions.ts
+++ b/packages/hoppscotch-common/src/helpers/actions.ts
@@ -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
+ }
}
/**
diff --git a/packages/hoppscotch-common/src/pages/index.vue b/packages/hoppscotch-common/src/pages/index.vue
index af4897acd..dcb8cfd35 100644
--- a/packages/hoppscotch-common/src/pages/index.vue
+++ b/packages/hoppscotch-common/src/pages/index.vue
@@ -84,6 +84,13 @@
:show="savingRequest"
@hide-modal="onSaveModalClose"
/>
+
@@ -138,6 +145,24 @@ const reqName = ref("")
const t = useI18n()
const toast = useToast()
+type PopupDetails = {
+ show: boolean
+ position: {
+ top: number
+ left: number
+ }
+ text: string | null
+}
+
+const contextMenu = ref({
+ 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()
diff --git a/packages/hoppscotch-common/src/services/context-menu/__tests__/index.spec.ts b/packages/hoppscotch-common/src/services/context-menu/__tests__/index.spec.ts
new file mode 100644
index 000000000..627775d08
--- /dev/null
+++ b/packages/hoppscotch-common/src/services/context-menu/__tests__/index.spec.ts
@@ -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([])
+ })
+ })
+})
diff --git a/packages/hoppscotch-common/src/services/context-menu/index.ts b/packages/hoppscotch-common/src/services/context-menu/index.ts
new file mode 100644
index 000000000..609ca7b64
--- /dev/null
+++ b/packages/hoppscotch-common/src/services/context-menu/index.ts
@@ -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 =
+ | {
+ 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 ? 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
+ /**
+ * 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 = 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
+ }
+}
diff --git a/packages/hoppscotch-common/src/services/context-menu/menu/__tests__/environment.menu.spec.ts b/packages/hoppscotch-common/src/services/context-menu/menu/__tests__/environment.menu.spec.ts
new file mode 100644
index 000000000..0f9a35923
--- /dev/null
+++ b/packages/hoppscotch-common/src/services/context-menu/menu/__tests__/environment.menu.spec.ts
@@ -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,
+ }
+ )
+ })
+ })
+})
diff --git a/packages/hoppscotch-common/src/services/context-menu/menu/__tests__/parameter.menu.spec.ts b/packages/hoppscotch-common/src/services/context-menu/menu/__tests__/parameter.menu.spec.ts
new file mode 100644
index 000000000..bfd0fc70e
--- /dev/null
+++ b/packages/hoppscotch-common/src/services/context-menu/menu/__tests__/parameter.menu.spec.ts
@@ -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)
+ })
+ })
+ })
+})
diff --git a/packages/hoppscotch-common/src/services/context-menu/menu/__tests__/url.menu.spec.ts b/packages/hoppscotch-common/src/services/context-menu/menu/__tests__/url.menu.spec.ts
new file mode 100644
index 000000000..5b4b0d31e
--- /dev/null
+++ b/packages/hoppscotch-common/src/services/context-menu/menu/__tests__/url.menu.spec.ts
@@ -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,
+ })
+ })
+ })
+})
diff --git a/packages/hoppscotch-common/src/services/context-menu/menu/environment.menu.ts b/packages/hoppscotch-common/src/services/context-menu/menu/environment.menu.ts
new file mode 100644
index 000000000..1882bda4b
--- /dev/null
+++ b/packages/hoppscotch-common/src/services/context-menu/menu/environment.menu.ts
@@ -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): ContextMenuState {
+ const results = ref([])
+ 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 = {
+ results: results.value,
+ }
+ return resultObj
+ }
+}
diff --git a/packages/hoppscotch-common/src/services/context-menu/menu/parameter.menu.ts b/packages/hoppscotch-common/src/services/context-menu/menu/parameter.menu.ts
new file mode 100644
index 000000000..ecb468ced
--- /dev/null
+++ b/packages/hoppscotch-common/src/services/context-menu/menu/parameter.menu.ts
@@ -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): ContextMenuState {
+ const results = ref([])
+
+ 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 = {
+ results: results.value,
+ }
+
+ return resultObj
+ }
+}
diff --git a/packages/hoppscotch-common/src/services/context-menu/menu/url.menu.ts b/packages/hoppscotch-common/src/services/context-menu/menu/url.menu.ts
new file mode 100644
index 000000000..fcec234ce
--- /dev/null
+++ b/packages/hoppscotch-common/src/services/context-menu/menu/url.menu.ts
@@ -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): ContextMenuState {
+ const results = ref([])
+
+ 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 = {
+ results: results.value,
+ }
+
+ return resultObj
+ }
+}
diff --git a/packages/hoppscotch-ui/package.json b/packages/hoppscotch-ui/package.json
index 79c5afeff..1c1a04929 100644
--- a/packages/hoppscotch-ui/package.json
+++ b/packages/hoppscotch-ui/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3919b203a..35c19d7ee 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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: