diff --git a/packages/hoppscotch-common/src/components/http/RequestTab.vue b/packages/hoppscotch-common/src/components/http/RequestTab.vue
index 3fc63907..6e9a65a0 100644
--- a/packages/hoppscotch-common/src/components/http/RequestTab.vue
+++ b/packages/hoppscotch-common/src/components/http/RequestTab.vue
@@ -9,7 +9,11 @@
/>
-
+
diff --git a/packages/hoppscotch-common/src/components/http/Response.vue b/packages/hoppscotch-common/src/components/http/Response.vue
index 95289a1b..58f5b243 100644
--- a/packages/hoppscotch-common/src/components/http/Response.vue
+++ b/packages/hoppscotch-common/src/components/http/Response.vue
@@ -5,6 +5,7 @@
v-if="!loading && hasResponse"
v-model:document="doc"
:is-editable="false"
+ :tab-id="tabId"
@save-as-example="saveAsExample"
/>
@@ -40,6 +41,7 @@ const toast = useToast()
const props = defineProps<{
document: HoppRequestDocument
+ tabId: string
isEmbed: boolean
}>()
@@ -68,7 +70,6 @@ const loading = computed(() => doc.value.response?.type === "loading")
const saveAsExample = () => {
showSaveResponseName.value = true
-
responseName.value = doc.value.request.name
}
@@ -125,14 +126,13 @@ const onSaveAsExample = () => {
showSaveResponseName.value = false
const saveCtx = doc.value.saveContext
-
if (!saveCtx) return
const req = doc.value.request
+
if (saveCtx.originLocation === "user-collection") {
try {
editRESTRequest(saveCtx.folderPath, saveCtx.requestIndex, req)
-
toast.success(`${t("response.saved")}`)
responseName.value = ""
} catch (e) {
@@ -152,7 +152,6 @@ const onSaveAsExample = () => {
responseName.value = ""
} else {
doc.value.isDirty = false
-
toast.success(`${t("request.saved")}`)
responseName.value = ""
}
diff --git a/packages/hoppscotch-common/src/components/lenses/ResponseBodyRenderer.vue b/packages/hoppscotch-common/src/components/lenses/ResponseBodyRenderer.vue
index 4395530d..c284ae0c 100644
--- a/packages/hoppscotch-common/src/components/lenses/ResponseBodyRenderer.vue
+++ b/packages/hoppscotch-common/src/components/lenses/ResponseBodyRenderer.vue
@@ -16,6 +16,7 @@
v-model:response="doc.response"
:is-savable="isSavable"
:is-editable="isEditable"
+ :tab-id="props.tabId"
@save-as-example="$emit('save-as-example')"
/>
@@ -76,6 +77,7 @@ import { ConsoleEntry } from "../console/Panel.vue"
const props = defineProps<{
document: HoppRequestDocument
isEditable: boolean
+ tabId: string
isTestRunner?: boolean
}>()
diff --git a/packages/hoppscotch-common/src/components/lenses/renderers/HTMLLensRenderer.vue b/packages/hoppscotch-common/src/components/lenses/renderers/HTMLLensRenderer.vue
index f4e48ead..86e2aea7 100644
--- a/packages/hoppscotch-common/src/components/lenses/renderers/HTMLLensRenderer.vue
+++ b/packages/hoppscotch-common/src/components/lenses/renderers/HTMLLensRenderer.vue
@@ -61,7 +61,11 @@
/>
-
-
+
()
+const { containerRef } = useScrollerRef(
+ "JSONLens",
+ ".cm-scroller",
+ undefined, // skip initial
+ `${props.tabId}::json`
+)
+
const emit = defineEmits<{
(e: "save-as-example"): void
(e: "update:response", val: HoppRESTRequestResponse | HoppRESTResponse): void
diff --git a/packages/hoppscotch-common/src/components/lenses/renderers/RawLensRenderer.vue b/packages/hoppscotch-common/src/components/lenses/renderers/RawLensRenderer.vue
index 56fff62f..154c98f3 100644
--- a/packages/hoppscotch-common/src/components/lenses/renderers/RawLensRenderer.vue
+++ b/packages/hoppscotch-common/src/components/lenses/renderers/RawLensRenderer.vue
@@ -52,7 +52,10 @@
/>
-
@@ -81,6 +84,7 @@ import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
import { useNestedSetting } from "~/composables/settings"
import { toggleNestedSetting } from "~/newstore/settings"
import { HoppRESTRequestResponse } from "@hoppscotch/data"
+import { useScrollerRef } from "~/composables/useScrollerRef"
const t = useI18n()
@@ -90,6 +94,7 @@ const props = defineProps<{
| HoppRESTRequestResponse
isEditable: boolean
isSavable: boolean
+ tabId: string
}>()
const emit = defineEmits<{
@@ -102,6 +107,13 @@ const emit = defineEmits<{
(e: "save-as-example"): void
}>()
+const { containerRef } = useScrollerRef(
+ "RawLens",
+ ".cm-scroller",
+ undefined, // skip initial scrollTop
+ `${props.tabId}::raw` // unique scroll key for RawLens
+)
+
const { responseBodyText } = useResponseBody(props.response)
const isHttpResponse = computed(() => {
diff --git a/packages/hoppscotch-common/src/components/lenses/renderers/XMLLensRenderer.vue b/packages/hoppscotch-common/src/components/lenses/renderers/XMLLensRenderer.vue
index 3e6e4963..4305c9e7 100644
--- a/packages/hoppscotch-common/src/components/lenses/renderers/XMLLensRenderer.vue
+++ b/packages/hoppscotch-common/src/components/lenses/renderers/XMLLensRenderer.vue
@@ -51,8 +51,9 @@
/>
-
@@ -80,6 +81,7 @@ import { objFieldMatches } from "~/helpers/functional/object"
import { useNestedSetting } from "~/composables/settings"
import { toggleNestedSetting } from "~/newstore/settings"
import { HoppRESTRequestResponse } from "@hoppscotch/data"
+import { useScrollerRef } from "~/composables/useScrollerRef"
const t = useI18n()
@@ -89,8 +91,16 @@ const props = defineProps<{
| HoppRESTRequestResponse
isEditable: boolean
isSavable: boolean
+ tabId: string
}>()
+const { containerRef } = useScrollerRef(
+ "XMLLens",
+ ".cm-scroller",
+ undefined, // skip initial
+ `${props.tabId}::xml`
+)
+
const emit = defineEmits<{
(e: "save-as-example"): void
}>()
diff --git a/packages/hoppscotch-common/src/composables/useScrollerRef.ts b/packages/hoppscotch-common/src/composables/useScrollerRef.ts
new file mode 100644
index 00000000..d3587156
--- /dev/null
+++ b/packages/hoppscotch-common/src/composables/useScrollerRef.ts
@@ -0,0 +1,113 @@
+import { ref, onMounted, onBeforeUnmount } from "vue"
+import { useService } from "dioc/vue"
+import { ScrollService } from "~/services/scroll.service"
+
+/**
+ * A composable used to automatically restore and save scroll position
+ * inside a scrollable element (e.g., .cm-scroller) within a container.
+ *
+ * @param label - Label used in error logging
+ * @param classSelector - CSS selector for the scrollable element
+ * @param initialScrollTop - Optional fallback scroll position
+ * @param scrollKey - Unique key used for saving/restoring scroll state via ScrollService
+ */
+export function useScrollerRef(
+ label: string = "Lens",
+ classSelector: string = ".cm-scroller",
+ initialScrollTop?: number,
+ scrollKey?: string
+) {
+ // Container element ref (typically the root of the scrollable section)
+ const containerRef = ref(null)
+
+ // Ref for the actual scrollable element inside the container
+ const scrollerRef = ref(null)
+
+ // Inject the ScrollService to access stored scroll positions
+ const scrollService = useService(ScrollService)
+
+ /**
+ * Utility to wait until the scrollable element is actually scrollable
+ * (i.e., content overflows and scrolling is possible).
+ * Retries for a limited number of times before failing.
+ */
+ let isUnmounted = false
+ function waitUntilScrollable(
+ maxTries = 60,
+ delay = 16
+ ): Promise {
+ return new Promise((resolve, reject) => {
+ let tries = 0
+
+ const tryFind = () => {
+ if (isUnmounted) {
+ reject(new Error(`[${label}] Aborted: component unmounted`))
+ return
+ }
+
+ const scroller = containerRef.value?.querySelector(
+ classSelector
+ ) as HTMLElement | null
+
+ if (scroller && scroller.scrollHeight > scroller.clientHeight) {
+ resolve(scroller) // Found a scrollable element
+ return
+ }
+
+ tries++
+ if (tries >= maxTries) {
+ reject(
+ new Error(`[${label}] Timeout: ${classSelector} never scrollable`)
+ )
+ } else {
+ setTimeout(tryFind, delay) // Retry after delay
+ }
+ }
+
+ tryFind()
+ })
+ }
+
+ // Scroll event handler to save scroll position
+ let onScroll: (() => void) | null = null
+
+ onMounted(async () => {
+ try {
+ const scroller = await waitUntilScrollable()
+ scrollerRef.value = scroller
+
+ // Restore scroll position from service (if available)
+ requestAnimationFrame(() => {
+ if (
+ scrollKey &&
+ scrollService.getScrollForKey(scrollKey) !== undefined
+ ) {
+ scroller.scrollTop = scrollService.getScrollForKey(scrollKey)!
+ } else if (initialScrollTop !== undefined) {
+ scroller.scrollTop = initialScrollTop
+ }
+ })
+
+ // Register scroll event to update position in ScrollService
+ onScroll = () => {
+ if (scrollKey) {
+ scrollService.setScrollForKey(scrollKey, scroller.scrollTop)
+ }
+ }
+
+ scroller.addEventListener("scroll", onScroll)
+ } catch (error: any) {
+ console.error(`[${label}] Failed to initialize scroller:`, error.message)
+ }
+ })
+
+ // Clean up scroll listener on unmount
+ onBeforeUnmount(() => {
+ isUnmounted = true
+ if (scrollerRef.value && onScroll) {
+ scrollerRef.value.removeEventListener("scroll", onScroll)
+ }
+ })
+
+ return { containerRef, scrollerRef }
+}
diff --git a/packages/hoppscotch-common/src/pages/index.vue b/packages/hoppscotch-common/src/pages/index.vue
index a7fe1b17..a330156f 100644
--- a/packages/hoppscotch-common/src/pages/index.vue
+++ b/packages/hoppscotch-common/src/pages/index.vue
@@ -151,6 +151,9 @@ import { cloneDeep } from "lodash-es"
import { RESTTabService } from "~/services/tab/rest"
import { HoppTab } from "~/services/tab"
import { HoppRequestDocument, HoppTabDocument } from "~/helpers/rest/document"
+import { ScrollService } from "~/services/scroll.service"
+
+const scrollService = useService(ScrollService)
const savingRequest = ref(false)
const confirmingCloseForTabID = ref(null)
@@ -249,6 +252,7 @@ const removeTab = (tabID: string) => {
if (tabState.document.isDirty) {
confirmingCloseForTabID.value = tabID
} else {
+ scrollService.cleanupScrollForTab(tabState.id)
tabs.closeTab(tabState.id)
inspectionService.deleteTabInspectorResult(tabState.id)
}
@@ -266,6 +270,7 @@ const closeOtherTabsAction = (tabID: string) => {
unsavedTabsCount.value = balanceDirtyTabCount
exceptedTabID.value = tabID
} else {
+ scrollService.cleanupAllScroll(tabID)
tabs.closeOtherTabs(tabID)
}
}
@@ -283,7 +288,10 @@ const duplicateTab = (tabID: string) => {
}
const onResolveConfirmCloseAllTabs = () => {
- if (exceptedTabID.value) tabs.closeOtherTabs(exceptedTabID.value)
+ if (exceptedTabID.value) {
+ scrollService.cleanupAllScroll(exceptedTabID.value)
+ tabs.closeOtherTabs(exceptedTabID.value)
+ }
confirmingCloseAllTabs.value = false
}
diff --git a/packages/hoppscotch-common/src/services/__tests__/scroll.service.spec.ts b/packages/hoppscotch-common/src/services/__tests__/scroll.service.spec.ts
new file mode 100644
index 00000000..8e76aac1
--- /dev/null
+++ b/packages/hoppscotch-common/src/services/__tests__/scroll.service.spec.ts
@@ -0,0 +1,99 @@
+import { describe, it, expect, beforeEach } from "vitest"
+import { TestContainer } from "dioc/testing"
+import { ScrollService } from "../scroll.service"
+
+describe("ScrollService", () => {
+ let service: ScrollService
+
+ beforeEach(() => {
+ const container = new TestContainer()
+ service = container.bind(ScrollService)
+ })
+
+ it("should store and retrieve scroll position for tab and suffix", () => {
+ service.setScroll("tab1", "json", 100)
+
+ expect(service.getScroll("tab1", "json")).toBe(100)
+ })
+
+ it("should return undefined for unset scroll position", () => {
+ expect(service.getScroll("tab1", "html")).toBeUndefined()
+ })
+
+ it("should clean up scroll positions for a specific tab", () => {
+ service.setScroll("tab1", "json", 100)
+ service.setScroll("tab1", "html", 200)
+
+ service.cleanupScrollForTab("tab1")
+
+ expect(service.getScroll("tab1", "json")).toBeUndefined()
+ expect(service.getScroll("tab1", "html")).toBeUndefined()
+ })
+
+ it("should clean up all scroll positions", () => {
+ service.setScroll("tab1", "json", 100)
+ service.setScroll("tab2", "html", 200)
+ service.cleanupAllScroll()
+
+ expect(service.getScroll("tab1", "json")).toBeUndefined()
+ expect(service.getScroll("tab2", "html")).toBeUndefined()
+ })
+
+ it("should clean up scroll positions for all tabs except a specific one", () => {
+ service.setScroll("tab1", "json", 100)
+ service.setScroll("tab2", "html", 200)
+ service.cleanupAllScroll("tab1")
+
+ expect(service.getScroll("tab1", "json")).toBe(100)
+ expect(service.getScroll("tab2", "html")).toBeUndefined()
+ })
+
+ it("should store and retrieve scroll position using a custom key", () => {
+ service.setScrollForKey("customKey", 999)
+
+ expect(service.getScrollForKey("customKey")).toBe(999)
+ })
+
+ it("should return undefined for unset custom key", () => {
+ expect(service.getScrollForKey("nonexistentKey")).toBeUndefined()
+ })
+
+ it("should overwrite an existing scroll value", () => {
+ service.setScroll("tab1", "json", 100)
+ service.setScroll("tab1", "json", 300)
+
+ expect(service.getScroll("tab1", "json")).toBe(300)
+ })
+
+ it("custom key and tab+suffix keys do not interfere when keys are different", () => {
+ service.setScroll("tab1", "json", 111)
+ service.setScrollForKey("custom-tab1-json", 999)
+ expect(service.getScroll("tab1", "json")).toBe(111)
+ expect(service.getScrollForKey("custom-tab1-json")).toBe(999)
+ })
+
+ it("cleanupScrollForTab should not remove other tab data", () => {
+ service.setScroll("tab1", "json", 100)
+ service.setScroll("tab2", "json", 200)
+
+ service.cleanupScrollForTab("tab1")
+
+ expect(service.getScroll("tab1", "json")).toBeUndefined()
+ expect(service.getScroll("tab2", "json")).toBe(200)
+ })
+
+ it("should handle empty tabId and suffix gracefully", () => {
+ service.setScroll("", "json", 123)
+ service.setScroll("tab1", "" as any, 456)
+
+ expect(service.getScroll("", "json")).toBe(123)
+ expect(service.getScroll("tab1", "" as any)).toBe(456)
+ })
+
+ it("should overwrite scroll position for custom keys", () => {
+ service.setScrollForKey("key1", 100)
+ service.setScrollForKey("key1", 300)
+
+ expect(service.getScrollForKey("key1")).toBe(300)
+ })
+})
diff --git a/packages/hoppscotch-common/src/services/scroll.service.ts b/packages/hoppscotch-common/src/services/scroll.service.ts
new file mode 100644
index 00000000..9f057e71
--- /dev/null
+++ b/packages/hoppscotch-common/src/services/scroll.service.ts
@@ -0,0 +1,98 @@
+import { Service } from "dioc"
+
+/**
+ * Suffix type for different views in the application.
+ * This is used to identify the type of view for which the scroll position is being stored.
+ */
+export type Suffix = "json" | "raw" | "html" | "xml" | "preview"
+
+/**
+ * This service is used to store and manage scroll positions for different tabs and views.
+ * It keeps track of scroll positions using a key-value mapping where each key
+ * is a combination of tab ID and view suffix (like json, raw, html, etc.).
+ *
+ * The scroll data is maintained in-memory and not persisted anywhere.
+ */
+export class ScrollService extends Service {
+ public static readonly ID = "SCROLL_SERVICE"
+
+ /**
+ * Internal map to store scroll positions.
+ * The key format is: `${tabId}::${suffix}` or any custom key.
+ */
+ private scrollMap = new Map()
+
+ /**
+ * Set the scroll position for a specific tab and view.
+ * @param tabId ID of the tab (e.g., request ID or panel ID)
+ * @param suffix View identifier (e.g., 'json', 'html', etc.)
+ * @param position Scroll position to store
+ */
+ public setScroll(tabId: string, suffix: Suffix, position: number) {
+ const key = `${tabId}::${suffix}`
+ this.scrollMap.set(key, position)
+ }
+
+ /**
+ * Get the scroll position for a specific tab and view.
+ * @param tabId ID of the tab
+ * @param suffix View identifier
+ * @returns Scroll position if available, otherwise undefined
+ */
+ public getScroll(tabId: string, suffix: Suffix): number | undefined {
+ const key = `${tabId}::${suffix}`
+ return this.scrollMap.get(key)
+ }
+
+ /**
+ * Clear scroll positions for all suffixes (views) related to a given tab.
+ * @param tabId ID of the tab
+ */
+ public cleanupScrollForTab(tabId: string) {
+ const keysToDelete = Array.from(this.scrollMap.keys())
+ for (const key of keysToDelete) {
+ const tabKey = key.split("::")[0]
+ if (tabKey === tabId) {
+ this.scrollMap.delete(key)
+ }
+ }
+ }
+
+ /**
+ * Clear all scroll positions from the service.
+ * If no tabId is provided, all scroll positions will be cleared.
+ * @param tabId - ID of the tab not to clear.
+ */
+ public cleanupAllScroll(tabId?: string) {
+ if (tabId) {
+ const keysToDelete = Array.from(this.scrollMap.keys())
+ for (const key of keysToDelete) {
+ const tabKey = key.split("::")[0]
+ if (tabKey !== tabId) {
+ this.scrollMap.delete(key)
+ }
+ }
+ } else {
+ this.scrollMap.clear()
+ }
+ }
+
+ /**
+ * Set scroll position directly by key (without tabId and suffix).
+ * Useful for custom or global use cases.
+ * @param key Unique identifier for scroll state
+ * @param position Scroll position to store
+ */
+ public setScrollForKey(key: string, position: number) {
+ this.scrollMap.set(key, position)
+ }
+
+ /**
+ * Get scroll position by key.
+ * @param key Unique identifier for scroll state
+ * @returns Scroll position if available, otherwise undefined
+ */
+ public getScrollForKey(key: string): number | undefined {
+ return this.scrollMap.get(key)
+ }
+}