feat(common): add erase response functionality with keybindings (#5435)

Co-authored-by: nivedin <nivedinp@gmail.com>
Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Fahed Khan 2025-11-26 14:24:16 +05:30 committed by GitHub
parent fdbec04703
commit 4c1911c007
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 326 additions and 28 deletions

View file

@ -12,6 +12,7 @@
"clear_cache": "Clear Cache",
"clear_history": "Clear all History",
"clear_unpinned": "Clear Unpinned",
"clear_response": "Clear Response",
"close": "Close",
"confirm": "Confirm",
"connect": "Connect",

View file

@ -6,9 +6,8 @@
<label class="truncate font-semibold text-secondaryLight">
{{ t("response.body") }}
</label>
<div class="flex">
<div v-if="response.body" class="flex">
<HoppButtonSecondary
v-if="response.body"
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="`${t(
'action.download_file'
@ -16,6 +15,34 @@
:icon="downloadIcon"
@click="downloadResponse"
/>
<tippy
v-if="!isEditable"
interactive
trigger="click"
theme="popover"
:on-shown="() => responseMoreActionsTippy?.focus()"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMore"
/>
<template #content="{ hide }">
<div
ref="responseMoreActionsTippy"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
:label="t('action.clear_response')"
:icon="IconEraser"
:shortcut="[getSpecialKey(), 'Delete']"
@click="eraseResponse"
/>
</div>
</template>
</tippy>
</div>
</div>
<div class="flex flex-1 items-center justify-center overflow-auto">
@ -25,7 +52,7 @@
</template>
<script setup lang="ts">
import { computed } from "vue"
import { computed, ref } from "vue"
import { useI18n } from "@composables/i18n"
import { useDownloadResponse } from "@composables/lens-actions"
import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
@ -37,6 +64,9 @@ import * as RNEA from "fp-ts/ReadonlyNonEmptyArray"
import * as A from "fp-ts/Array"
import * as O from "fp-ts/Option"
import { objFieldMatches } from "~/helpers/functional/object"
import { HoppRESTRequestResponse } from "@hoppscotch/data"
import IconEraser from "~icons/lucide/eraser"
import IconMore from "~icons/lucide/more-horizontal"
const t = useI18n()
@ -44,6 +74,11 @@ const props = defineProps<{
response: HoppRESTResponse & {
type: "success" | "fail"
}
isEditable: boolean
}>()
const emit = defineEmits<{
(e: "update:response", val: HoppRESTRequestResponse | HoppRESTResponse): void
}>()
const audiosrc = computed(() =>
@ -54,6 +89,8 @@ const audiosrc = computed(() =>
)
)
const responseMoreActionsTippy = ref<HTMLElement | null>(null)
const responseType = computed(() =>
pipe(
props.response,
@ -78,5 +115,10 @@ const { downloadIcon, downloadResponse } = useDownloadResponse(
})
)
const eraseResponse = () => {
emit("update:response", null)
}
defineActionHandler("response.file.download", () => downloadResponse())
defineActionHandler("response.erase", () => eraseResponse())
</script>

View file

@ -7,9 +7,9 @@
<label class="truncate font-semibold text-secondaryLight">
{{ t("response.body") }}
</label>
<div class="flex">
<div v-if="response.body" class="flex">
<HoppButtonSecondary
v-if="response.body && !previewEnabled"
v-if="!previewEnabled"
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': WRAP_LINES }"
@ -17,7 +17,6 @@
@click.prevent="toggleNestedSetting('WRAP_LINES', 'httpResponseBody')"
/>
<HoppButtonSecondary
v-if="response.body"
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="`${
previewEnabled ? t('hide.preview') : t('response.preview_html')
@ -26,7 +25,6 @@
@click.prevent="doTogglePreview"
/>
<HoppButtonSecondary
v-if="response.body"
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="`${t(
'action.download_file'
@ -35,7 +33,7 @@
@click="downloadResponse"
/>
<HoppButtonSecondary
v-if="response.body"
v-if="!isEditable"
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="
isSavable
@ -51,7 +49,6 @@
@click="isSavable ? saveAsExample() : null"
/>
<HoppButtonSecondary
v-if="response.body"
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="`${t(
'action.copy'
@ -59,6 +56,34 @@
:icon="copyIcon"
@click="copyResponse"
/>
<tippy
v-if="!isEditable"
interactive
trigger="click"
theme="popover"
:on-shown="() => responseMoreActionsTippy?.focus()"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMore"
/>
<template #content="{ hide }">
<div
ref="responseMoreActionsTippy"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
:label="t('action.clear_response')"
:icon="IconEraser"
:shortcut="[getSpecialKey(), 'Delete']"
@click="eraseResponse"
/>
</div>
</template>
</tippy>
</div>
</div>
<div
@ -101,6 +126,8 @@ import IconEye from "~icons/lucide/eye"
import IconEyeOff from "~icons/lucide/eye-off"
import IconWrapText from "~icons/lucide/wrap-text"
import IconSave from "~icons/lucide/save"
import IconEraser from "~icons/lucide/eraser"
import IconMore from "~icons/lucide/more-horizontal"
import { HoppRESTRequestResponse } from "@hoppscotch/data"
import { computedAsync } from "@vueuse/core"
import { useScrollerRef } from "~/composables/useScrollerRef"
@ -126,9 +153,14 @@ const { containerRef } = useScrollerRef(
const emit = defineEmits<{
(e: "save-as-example"): void
(
e: "update:response",
val: HoppRESTRequestResponse | HoppRESTResponse | null
): void
}>()
const htmlResponse = ref<any | null>(null)
const responseMoreActionsTippy = ref<HTMLElement | null>(null)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpResponseBody")
const responseName = computed(() => {
@ -174,6 +206,10 @@ const doTogglePreview = async () => {
const { copyIcon, copyResponse } = useCopyResponse(responseBodyText)
const eraseResponse = () => {
emit("update:response", null)
}
const saveAsExample = () => {
emit("save-as-example")
}
@ -196,6 +232,7 @@ useCodemirror(
defineActionHandler("response.preview.toggle", () => doTogglePreview())
defineActionHandler("response.file.download", () => downloadResponse())
defineActionHandler("response.copy", () => copyResponse())
defineActionHandler("response.erase", () => eraseResponse())
defineActionHandler("response.save-as-example", () => {
props.isSavable ? saveAsExample() : null
})

View file

@ -6,9 +6,8 @@
<label class="truncate font-semibold text-secondaryLight">
{{ t("response.body") }}
</label>
<div class="flex">
<div v-if="response.body" class="flex">
<HoppButtonSecondary
v-if="response.body"
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="`${t(
'action.download_file'
@ -16,6 +15,34 @@
:icon="downloadIcon"
@click="downloadResponse"
/>
<tippy
v-if="!isEditable"
interactive
trigger="click"
theme="popover"
:on-shown="() => responseMoreActionsTippy?.focus()"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMore"
/>
<template #content="{ hide }">
<div
ref="responseMoreActionsTippy"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
:label="t('action.clear_response')"
:icon="IconEraser"
:shortcut="[getSpecialKey(), 'Delete']"
@click="eraseResponse"
/>
</div>
</template>
</tippy>
</div>
</div>
<img
@ -40,14 +67,23 @@ import * as RNEA from "fp-ts/ReadonlyNonEmptyArray"
import * as A from "fp-ts/Array"
import * as O from "fp-ts/Option"
import { objFieldMatches } from "~/helpers/functional/object"
import { HoppRESTRequestResponse } from "@hoppscotch/data"
import IconEraser from "~icons/lucide/eraser"
import IconMore from "~icons/lucide/more-horizontal"
const t = useI18n()
const props = defineProps<{
response: HoppRESTResponse & { type: "success" | "fail" }
isEditable: boolean
}>()
const emit = defineEmits<{
(e: "update:response", val: HoppRESTRequestResponse | HoppRESTResponse): void
}>()
const imageSource = ref("")
const responseMoreActionsTippy = ref<HTMLElement | null>(null)
const responseType = computed(() =>
pipe(
@ -101,5 +137,10 @@ onMounted(() => {
reader.readAsDataURL(blob)
})
const eraseResponse = () => {
emit("update:response", null)
}
defineActionHandler("response.file.download", () => downloadResponse())
defineActionHandler("response.erase", () => eraseResponse())
</script>

View file

@ -59,7 +59,7 @@
@click="copyResponse"
/>
<tippy
v-if="showResponse"
v-if="showResponse && response.body && !isEditable"
interactive
trigger="click"
theme="popover"
@ -87,6 +87,12 @@
}
"
/>
<HoppSmartItem
:label="t('action.clear_response')"
:icon="IconEraser"
:shortcut="[getSpecialKey(), 'Delete']"
@click="eraseResponse"
/>
</div>
</template>
</tippy>
@ -252,6 +258,7 @@ import IconMore from "~icons/lucide/more-horizontal"
import IconHelpCircle from "~icons/lucide/help-circle"
import IconNetwork from "~icons/lucide/network"
import IconSave from "~icons/lucide/save"
import IconEraser from "~icons/lucide/eraser"
import * as LJSON from "lossless-json"
import * as O from "fp-ts/Option"
import * as E from "fp-ts/Either"
@ -458,6 +465,11 @@ const saveAsExample = () => {
}
const { copyIcon, copyResponse } = useCopyResponse(jsonBodyText)
const eraseResponse = () => {
emit("update:response", null)
}
const { downloadIcon, downloadResponse } = useDownloadResponse(
"application/json",
jsonBodyText,
@ -520,6 +532,7 @@ const toggleFilterState = () => {
defineActionHandler("response.file.download", () => downloadResponse())
defineActionHandler("response.copy", () => copyResponse())
defineActionHandler("response.erase", () => eraseResponse())
defineActionHandler("response.save-as-example", () => {
props.isSavable ? saveAsExample() : null
})

View file

@ -6,9 +6,8 @@
<label class="truncate font-semibold text-secondaryLight">
{{ t("response.body") }}
</label>
<div class="flex">
<div v-if="response.body" class="flex">
<HoppButtonSecondary
v-if="response.body"
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="`${t(
'action.download_file'
@ -16,6 +15,34 @@
:icon="downloadIcon"
@click="downloadResponse"
/>
<tippy
v-if="!isEditable"
interactive
trigger="click"
theme="popover"
:on-shown="() => responseMoreActionsTippy?.focus()"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMore"
/>
<template #content="{ hide }">
<div
ref="responseMoreActionsTippy"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
:label="t('action.clear_response')"
:icon="IconEraser"
:shortcut="[getSpecialKey(), 'Delete']"
@click="eraseResponse"
/>
</div>
</template>
</tippy>
</div>
</div>
<vue-pdf-embed
@ -27,20 +54,29 @@
</template>
<script setup lang="ts">
import { computed } from "vue"
import { computed, ref } from "vue"
import VuePdfEmbed from "vue-pdf-embed"
import { useI18n } from "@composables/i18n"
import { useDownloadResponse } from "@composables/lens-actions"
import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
import { defineActionHandler } from "~/helpers/actions"
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
import { HoppRESTRequestResponse } from "@hoppscotch/data"
import IconEraser from "~icons/lucide/eraser"
import IconMore from "~icons/lucide/more-horizontal"
const t = useI18n()
const responseMoreActionsTippy = ref<HTMLElement | null>(null)
const props = defineProps<{
response: HoppRESTResponse & {
type: "success" | "fail"
}
isEditable: boolean
}>()
const emit = defineEmits<{
(e: "update:response", val: HoppRESTRequestResponse | HoppRESTResponse): void
}>()
const pdfsrc = computed(() =>
@ -61,5 +97,10 @@ const { downloadIcon, downloadResponse } = useDownloadResponse(
`${filename}.pdf`
)
const eraseResponse = () => {
emit("update:response", null)
}
defineActionHandler("response.file.download", () => downloadResponse())
defineActionHandler("response.erase", () => eraseResponse())
</script>

View file

@ -7,9 +7,8 @@
<label class="truncate font-semibold text-secondaryLight">
{{ t("response.body") }}
</label>
<div class="flex">
<div v-if="response.body" class="flex">
<HoppButtonSecondary
v-if="response.body"
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': WRAP_LINES }"
@ -17,7 +16,6 @@
@click.prevent="toggleNestedSetting('WRAP_LINES', 'httpResponseBody')"
/>
<HoppButtonSecondary
v-if="response.body"
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="`${t(
'action.download_file'
@ -42,7 +40,6 @@
@click="isSavable ? saveAsExample() : null"
/>
<HoppButtonSecondary
v-if="response.body"
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="`${t(
'action.copy'
@ -50,6 +47,34 @@
:icon="copyIcon"
@click="copyResponse"
/>
<tippy
v-if="showResponse && !isEditable"
interactive
trigger="click"
theme="popover"
:on-shown="() => responseMoreActionsTippy?.focus()"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMore"
/>
<template #content="{ hide }">
<div
ref="responseMoreActionsTippy"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
:label="t('action.clear_response')"
:icon="IconEraser"
:shortcut="[getSpecialKey(), 'Delete']"
@click="eraseResponse"
/>
</div>
</template>
</tippy>
</div>
</div>
<div
@ -64,6 +89,8 @@
<script setup lang="ts">
import IconWrapText from "~icons/lucide/wrap-text"
import IconSave from "~icons/lucide/save"
import IconEraser from "~icons/lucide/eraser"
import IconMore from "~icons/lucide/more-horizontal"
import { ref, computed, reactive } from "vue"
import { flow, pipe } from "fp-ts/function"
import * as S from "fp-ts/string"
@ -139,6 +166,10 @@ const saveAsExample = () => {
emit("save-as-example")
}
const eraseResponse = () => {
emit("update:response", null)
}
const responseType = computed(() =>
pipe(
props.response,
@ -178,6 +209,7 @@ const { copyIcon, copyResponse } = useCopyResponse(responseBodyText)
const rawResponse = ref<any | null>(null)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpResponseBody")
const responseMoreActionsTippy = ref<HTMLElement | null>(null)
useCodemirror(
rawResponse,
@ -202,4 +234,5 @@ useCodemirror(
defineActionHandler("response.file.download", () => downloadResponse())
defineActionHandler("response.copy", () => copyResponse())
defineActionHandler("response.erase", () => eraseResponse())
</script>

View file

@ -6,9 +6,8 @@
<label class="truncate font-semibold text-secondaryLight">
{{ t("response.body") }}
</label>
<div class="flex">
<div v-if="response.body" class="flex">
<HoppButtonSecondary
v-if="response.body"
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="`${t(
'action.download_file'
@ -16,6 +15,34 @@
:icon="downloadIcon"
@click="downloadResponse"
/>
<tippy
v-if="!isEditable"
interactive
trigger="click"
theme="popover"
:on-shown="() => responseMoreActionsTippy?.focus()"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMore"
/>
<template #content="{ hide }">
<div
ref="responseMoreActionsTippy"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
:label="t('action.clear_response')"
:icon="IconEraser"
:shortcut="[getSpecialKey(), 'Delete']"
@click="eraseResponse"
/>
</div>
</template>
</tippy>
</div>
</div>
<div class="flex flex-1 items-center justify-center overflow-auto">
@ -25,7 +52,7 @@
</template>
<script setup lang="ts">
import { computed } from "vue"
import { computed, ref } from "vue"
import { useI18n } from "@composables/i18n"
import { useDownloadResponse } from "@composables/lens-actions"
import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
@ -37,6 +64,9 @@ import * as RNEA from "fp-ts/ReadonlyNonEmptyArray"
import * as A from "fp-ts/Array"
import * as O from "fp-ts/Option"
import { objFieldMatches } from "~/helpers/functional/object"
import { HoppRESTRequestResponse } from "@hoppscotch/data"
import IconEraser from "~icons/lucide/eraser"
import IconMore from "~icons/lucide/more-horizontal"
const t = useI18n()
@ -44,6 +74,11 @@ const props = defineProps<{
response: HoppRESTResponse & {
type: "success" | "fail"
}
isEditable: boolean
}>()
const emit = defineEmits<{
(e: "update:response", val: HoppRESTRequestResponse | HoppRESTResponse): void
}>()
const videosrc = computed(() =>
@ -54,6 +89,8 @@ const videosrc = computed(() =>
)
)
const responseMoreActionsTippy = ref<HTMLElement | null>(null)
const responseType = computed(() =>
pipe(
props.response,
@ -78,5 +115,10 @@ const { downloadIcon, downloadResponse } = useDownloadResponse(
})
)
const eraseResponse = () => {
emit("update:response", null)
}
defineActionHandler("response.file.download", () => downloadResponse())
defineActionHandler("response.erase", () => eraseResponse())
</script>

View file

@ -6,9 +6,8 @@
<label class="truncate font-semibold text-secondaryLight">
{{ t("response.body") }}
</label>
<div class="flex">
<div v-if="response.body" class="flex">
<HoppButtonSecondary
v-if="response.body"
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': WRAP_LINES }"
@ -16,7 +15,6 @@
@click.prevent="toggleNestedSetting('WRAP_LINES', 'httpResponseBody')"
/>
<HoppButtonSecondary
v-if="response.body"
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="`${t(
'action.download_file'
@ -25,7 +23,7 @@
@click="downloadResponse"
/>
<HoppButtonSecondary
v-if="response.body && !isEditable"
v-if="!isEditable"
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="
isSavable
@ -41,7 +39,6 @@
@click="isSavable ? saveAsExample() : null"
/>
<HoppButtonSecondary
v-if="response.body"
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="`${t(
'action.copy'
@ -49,6 +46,34 @@
:icon="copyIcon"
@click="copyResponse"
/>
<tippy
v-if="!isEditable"
interactive
trigger="click"
theme="popover"
:on-shown="() => responseMoreActionsTippy?.focus()"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMore"
/>
<template #content="{ hide }">
<div
ref="responseMoreActionsTippy"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
:label="t('action.clear_response')"
:icon="IconEraser"
:shortcut="[getSpecialKey(), 'Delete']"
@click="eraseResponse"
/>
</div>
</template>
</tippy>
</div>
</div>
@ -61,6 +86,8 @@
<script setup lang="ts">
import IconWrapText from "~icons/lucide/wrap-text"
import IconSave from "~icons/lucide/save"
import IconEraser from "~icons/lucide/eraser"
import IconMore from "~icons/lucide/more-horizontal"
import { computed, ref, reactive } from "vue"
import { flow, pipe } from "fp-ts/function"
import * as S from "fp-ts/string"
@ -103,8 +130,19 @@ const { containerRef } = useScrollerRef(
const emit = defineEmits<{
(e: "save-as-example"): void
(
e: "update:response",
val:
| (HoppRESTResponse & { type: "success" | "fail" })
| HoppRESTRequestResponse
| null
): void
}>()
const eraseResponse = () => {
emit("update:response", null)
}
const { responseBodyText } = useResponseBody(props.response)
const isHttpResponse = computed(() => {
@ -158,6 +196,7 @@ const { downloadIcon, downloadResponse } = useDownloadResponse(
const { copyIcon, copyResponse } = useCopyResponse(responseBodyText)
const xmlResponse = ref<any | null>(null)
const responseMoreActionsTippy = ref<HTMLElement | null>(null)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpResponseBody")
const saveAsExample = () => {
@ -184,4 +223,5 @@ defineActionHandler("response.copy", () => copyResponse())
defineActionHandler("response.save-as-example", () => {
props.isSavable ? saveAsExample() : null
})
defineActionHandler("response.erase", () => eraseResponse())
</script>

View file

@ -70,6 +70,7 @@ export type HoppAction =
| "response.schema.toggle" // Toggle response data schema
| "response.file.download" // Download response as file
| "response.copy" // Copy response to clipboard
| "response.erase" // Erase/clear response
| "response.save" // Save response
| "response.save-as-example" // Save response as example
| "modals.login.toggle" // Login to Hoppscotch

View file

@ -37,7 +37,7 @@ type Key =
| "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t"
| "u" | "v" | "w" | "x" | "y" | "z" | "0" | "1" | "2" | "3"
| "4" | "5" | "6" | "7" | "8" | "9" | "up" | "down" | "left"
| "right" | "/" | "?" | "." | "enter" | "tab"
| "right" | "/" | "?" | "." | "enter" | "tab" | "delete" | "backspace"
/* eslint-enable */
type ModifierBasedShortcutKey = `${ModifierKeys}-${Key}`
@ -77,6 +77,8 @@ const baseBindings: {
"ctrl-.": "response.copy",
"ctrl-e": "response.save-as-example",
"ctrl-shift-l": "editor.format",
"ctrl-delete": "response.erase",
"ctrl-backspace": "response.erase",
}
// Desktop-only bindings
@ -225,6 +227,11 @@ function getPressedKey(ev: KeyboardEvent): Key | null {
// Check for Tab key
if (key === "tab") return "tab"
// Check for Delete key
if (key === "delete") return "delete"
if (key === "backspace") return "backspace"
// Check letter keys
const isLetter = key.length === 1 && key >= "a" && key <= "z"
if (isLetter) return key as Key