From 2ad2f46e6a9f43842d59978b942f047747d47de3 Mon Sep 17 00:00:00 2001 From: Kanhaiya Pandey Date: Mon, 16 Feb 2026 16:55:47 +0530 Subject: [PATCH] feat(common): URL encode/decode context menu actions (#5782) Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com> --- packages/hoppscotch-common/locales/en.json | 4 +- .../services/context-menu/menu/url.menu.ts | 97 +++++++++++++++++-- 2 files changed, 91 insertions(+), 10 deletions(-) diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index 6c2e91f3..ec24f16b 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -379,7 +379,9 @@ "context_menu": { "add_parameters": "Add to parameters", "open_request_in_new_tab": "Open request in new tab", - "set_environment_variable": "Set as variable" + "set_environment_variable": "Set as variable", + "encode_uri_component": "Encode URL component", + "decode_uri_component": "Decode URL component" }, "cookies": { "modal": { 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 index dde4d61e..9d6c5f28 100644 --- a/packages/hoppscotch-common/src/services/context-menu/menu/url.menu.ts +++ b/packages/hoppscotch-common/src/services/context-menu/menu/url.menu.ts @@ -4,6 +4,8 @@ import { getDefaultRESTRequest } from "~/helpers/rest/default" import { getI18n } from "~/modules/i18n" import { RESTTabService } from "~/services/tab/rest" import IconCopyPlus from "~icons/lucide/copy-plus" +import IconLock from "~icons/lucide/lock" +import IconUnlock from "~icons/lucide/unlock" import { ContextMenu, ContextMenuResult, @@ -11,22 +13,43 @@ import { ContextMenuState, } from ".." +/** + * Checks if a string matches a URL via the URL constructor or a regex fallback. + * The URL constructor rejects endpoints like "localhost:3000" (no protocol), + * so the regex covers common patterns without a scheme. + */ +function checkURL(url: string): boolean { + try { + new URL(url) + return true + } catch { + return /^(https?:\/\/)?([\w.-]+)(\.[\w.-]+)+([/?].*)?$/.test(url) + } +} + /** * 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) + if (checkURL(url)) return true + + // Iteratively decode percent-encoded strings so that encode/decode + // round-trips work across multiple levels of encoding. + let current = url + for (let i = 0; i < 10 && current.includes("%"); i++) { + try { + const decoded = decodeURIComponent(current) + if (decoded === current) break + if (checkURL(decoded)) return true + current = decoded + } catch { + break + } } + + return false } export class URLMenuService extends Service implements ContextMenu { @@ -61,6 +84,31 @@ export class URLMenuService extends Service implements ContextMenu { }) } + /** + * Replaces the selected text in the current endpoint with encoded/decoded version + * @param selectedText The selected text to replace + * @param replacement The replacement text (encoded or decoded) + */ + private replaceSelectedText(selectedText: string, replacement: string) { + const currentTab = this.restTab.currentActiveTab.value + + if (!currentTab || currentTab.document.type !== "request") { + return + } + + const endpoint = currentTab.document.request.endpoint + + // Find and replace the selected text in the endpoint + const index = endpoint.indexOf(selectedText) + if (index === -1) return + + const newEndpoint = + endpoint.slice(0, index) + + replacement + + endpoint.slice(index + selectedText.length) + currentTab.document.request.endpoint = newEndpoint + } + getMenuFor(text: Readonly): ContextMenuState { const results = ref([]) @@ -77,6 +125,37 @@ export class URLMenuService extends Service implements ContextMenu { this.openNewTab(text) }, }, + { + id: "encode-url", + text: { + type: "text", + text: this.t("context_menu.encode_uri_component"), + }, + icon: markRaw(IconLock), + action: () => { + const encoded = encodeURIComponent(text) + this.replaceSelectedText(text, encoded) + }, + }, + { + id: "decode-url", + text: { + type: "text", + text: this.t("context_menu.decode_uri_component"), + }, + icon: markRaw(IconUnlock), + action: () => { + try { + const decoded = decodeURIComponent(text) + this.replaceSelectedText(text, decoded) + } catch (error) { + console.warn( + "[URLMenuService] Failed to decode URI component:", + error + ) + } + }, + }, ] }