feat: preserve response viewer scroll position per tab (#5193)

Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
Co-authored-by: nivedin <nivedinp@gmail.com>
This commit is contained in:
Vignesh p 2025-07-28 14:58:04 +05:30 committed by GitHub
parent deb2c41016
commit 98fa140b55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 381 additions and 11 deletions

View file

@ -9,7 +9,11 @@
/>
</template>
<template #secondary>
<HttpResponse v-model:document="tab.document" :is-embed="false" />
<HttpResponse
v-model:document="tab.document"
:tab-id="tab.id"
:is-embed="false"
/>
</template>
</AppPaneLayout>
</template>

View file

@ -5,6 +5,7 @@
v-if="!loading && hasResponse"
v-model:document="doc"
:is-editable="false"
:tab-id="tabId"
@save-as-example="saveAsExample"
/>
</div>
@ -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 = ""
}

View file

@ -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')"
/>
</HoppSmartTab>
@ -76,6 +77,7 @@ import { ConsoleEntry } from "../console/Panel.vue"
const props = defineProps<{
document: HoppRequestDocument
isEditable: boolean
tabId: string
isTestRunner?: boolean
}>()

View file

@ -61,7 +61,11 @@
/>
</div>
</div>
<div v-show="!previewEnabled" class="h-full relative flex flex-col flex-1">
<div
v-show="!previewEnabled"
ref="containerRef"
class="h-full relative flex flex-col flex-1"
>
<div ref="htmlResponse" class="absolute inset-0"></div>
</div>
<iframe
@ -99,6 +103,7 @@ import IconWrapText from "~icons/lucide/wrap-text"
import IconSave from "~icons/lucide/save"
import { HoppRESTRequestResponse } from "@hoppscotch/data"
import { computedAsync } from "@vueuse/core"
import { useScrollerRef } from "~/composables/useScrollerRef"
const t = useI18n()
const persistenceService = useService(PersistenceService)
@ -109,8 +114,16 @@ const props = defineProps<{
| HoppRESTRequestResponse
isSavable: boolean
isEditable: boolean
tabId: string
}>()
const { containerRef } = useScrollerRef(
"HTMLLens",
".cm-scroller",
undefined, // skip initial
`${props.tabId}::html`
)
const emit = defineEmits<{
(e: "save-as-example"): void
}>()

View file

@ -132,7 +132,10 @@
/>
</div>
</div>
<div class="h-full relative overflow-auto flex flex-col flex-1">
<div
ref="containerRef"
class="h-full relative overflow-auto flex flex-col flex-1"
>
<div ref="jsonResponse" class="absolute inset-0 h-full"></div>
</div>
<div
@ -274,6 +277,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()
@ -281,8 +285,16 @@ const props = defineProps<{
response: HoppRESTResponse | HoppRESTRequestResponse
isSavable: boolean
isEditable: boolean
tabId: string
}>()
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

View file

@ -52,7 +52,10 @@
/>
</div>
</div>
<div class="h-full relative overflow-auto flex flex-col flex-1">
<div
ref="containerRef"
class="h-full relative overflow-auto flex flex-col flex-1"
>
<div ref="rawResponse" class="absolute inset-0"></div>
</div>
</div>
@ -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(() => {

View file

@ -51,8 +51,9 @@
/>
</div>
</div>
<div class="h-full">
<div ref="xmlResponse" class="flex flex-1 flex-col"></div>
<div ref="containerRef" class="h-full relative flex flex-col flex-1">
<div ref="xmlResponse" class="absolute inset-0"></div>
</div>
</div>
</template>
@ -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
}>()

View file

@ -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<HTMLElement | null>(null)
// Ref for the actual scrollable element inside the container
const scrollerRef = ref<HTMLElement | null>(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<HTMLElement> {
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 }
}

View file

@ -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<string | null>(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
}

View file

@ -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)
})
})

View file

@ -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<string, number>()
/**
* 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)
}
}