feat: tab service added (#3367)
This commit is contained in:
@@ -11,15 +11,6 @@ vi.mock("~/modules/i18n", () => ({
|
||||
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()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TestContainer } from "dioc/testing"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { RESTTabService } from "~/services/tab/rest"
|
||||
import { ContextMenuService } from "../.."
|
||||
import { URLMenuService } from "../url.menu"
|
||||
import { getDefaultRESTRequest } from "~/helpers/rest/default"
|
||||
@@ -9,15 +10,6 @@ vi.mock("~/modules/i18n", () => ({
|
||||
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()
|
||||
@@ -64,6 +56,10 @@ describe("URLMenuService", () => {
|
||||
|
||||
it("should call the openNewTab function when action is called and a new hoppscotch tab is opened", () => {
|
||||
const container = new TestContainer()
|
||||
const createNewTabFn = vi.fn()
|
||||
container.bindMock(RESTTabService, {
|
||||
createNewTab: createNewTabFn,
|
||||
})
|
||||
const url = container.bind(URLMenuService)
|
||||
|
||||
const test = "https://hoppscotch.io"
|
||||
@@ -76,8 +72,8 @@ describe("URLMenuService", () => {
|
||||
endpoint: test,
|
||||
}
|
||||
|
||||
expect(tabMock.createNewTab).toHaveBeenCalledOnce()
|
||||
expect(tabMock.createNewTab).toHaveBeenCalledWith({
|
||||
expect(createNewTabFn).toHaveBeenCalledOnce()
|
||||
expect(createNewTabFn).toHaveBeenCalledWith({
|
||||
request: request,
|
||||
isDirty: false,
|
||||
})
|
||||
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
} 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"
|
||||
import { RESTTabService } from "~/services/tab/rest"
|
||||
import { getService } from "~/modules/dioc"
|
||||
|
||||
//regex containing both url and parameter
|
||||
const urlAndParameterRegex = new RegExp("[^&?]*?=[^&?]*")
|
||||
@@ -88,20 +89,23 @@ export class ParameterMenuService extends Service implements ContextMenu {
|
||||
queryParams.push({ key, value, active: true })
|
||||
}
|
||||
|
||||
const tabService = getService(RESTTabService)
|
||||
|
||||
// add the parameters to the current request parameters
|
||||
currentActiveTab.value.document.request.params = [
|
||||
...currentActiveTab.value.document.request.params,
|
||||
tabService.currentActiveTab.value.document.request.params = [
|
||||
...tabService.currentActiveTab.value.document.request.params,
|
||||
...queryParams,
|
||||
]
|
||||
|
||||
if (newURL) {
|
||||
currentActiveTab.value.document.request.endpoint = newURL
|
||||
tabService.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 sanitizedWord =
|
||||
tabService.currentActiveTab.value.document.request.endpoint
|
||||
const newURL = sanitizedWord.replace(textRegex, "")
|
||||
currentActiveTab.value.document.request.endpoint = newURL
|
||||
tabService.currentActiveTab.value.document.request.endpoint = newURL
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Service } from "dioc"
|
||||
import { markRaw, ref } from "vue"
|
||||
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 {
|
||||
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
|
||||
@@ -37,6 +37,7 @@ export class URLMenuService extends Service implements ContextMenu {
|
||||
public readonly menuID = "url"
|
||||
|
||||
private readonly contextMenu = this.bind(ContextMenuService)
|
||||
private readonly restTab = this.bind(RESTTabService)
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
@@ -55,7 +56,7 @@ export class URLMenuService extends Service implements ContextMenu {
|
||||
endpoint: url,
|
||||
}
|
||||
|
||||
createNewTab({
|
||||
this.restTab.createNewTab({
|
||||
request: request,
|
||||
isDirty: false,
|
||||
})
|
||||
|
||||
@@ -3,8 +3,8 @@ import { refDebounced } from "@vueuse/core"
|
||||
import { Service } from "dioc"
|
||||
import { computed, markRaw, reactive } from "vue"
|
||||
import { Component, Ref, ref, watch } from "vue"
|
||||
import { currentActiveTab } from "~/helpers/rest/tab"
|
||||
import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
|
||||
import { RESTTabService } from "../tab/rest"
|
||||
|
||||
/**
|
||||
* Defines how to render the text in an Inspector Result
|
||||
@@ -105,6 +105,8 @@ export class InspectionService extends Service {
|
||||
|
||||
private tabs: Ref<Map<string, InspectorResult[]>> = ref(new Map())
|
||||
|
||||
private readonly restTab = this.bind(RESTTabService)
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
@@ -122,10 +124,14 @@ export class InspectionService extends Service {
|
||||
|
||||
private initializeListeners() {
|
||||
watch(
|
||||
() => [this.inspectors.entries(), currentActiveTab.value.id],
|
||||
() => [this.inspectors.entries(), this.restTab.currentActiveTab.value.id],
|
||||
() => {
|
||||
const reqRef = computed(() => currentActiveTab.value.document.request)
|
||||
const resRef = computed(() => currentActiveTab.value.response)
|
||||
const reqRef = computed(
|
||||
() => this.restTab.currentActiveTab.value.document.request
|
||||
)
|
||||
const resRef = computed(
|
||||
() => this.restTab.currentActiveTab.value.document.response
|
||||
)
|
||||
|
||||
const debouncedReq = refDebounced(reqRef, 1000, { maxWait: 2000 })
|
||||
const debouncedRes = refDebounced(resRef, 1000, { maxWait: 2000 })
|
||||
@@ -142,7 +148,7 @@ export class InspectionService extends Service {
|
||||
() => [...inspectorRefs.flatMap((x) => x!.value)],
|
||||
() => {
|
||||
this.tabs.value.set(
|
||||
currentActiveTab.value.id,
|
||||
this.restTab.currentActiveTab.value.id,
|
||||
activeInspections.value
|
||||
)
|
||||
},
|
||||
|
||||
@@ -7,20 +7,12 @@ import { GQLHistoryEntry, RESTHistoryEntry } from "~/newstore/history"
|
||||
import { getDefaultRESTRequest } from "~/helpers/rest/default"
|
||||
import { HoppAction, HoppActionWithArgs } from "~/helpers/actions"
|
||||
import { getDefaultGQLRequest } from "~/helpers/graphql/default"
|
||||
import { RESTTabService } from "~/services/tab/rest"
|
||||
|
||||
async function flushPromises() {
|
||||
return await new Promise((r) => setTimeout(r))
|
||||
}
|
||||
|
||||
const tabMock = vi.hoisted(() => ({
|
||||
createNewTab: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("~/helpers/rest/tab", () => ({
|
||||
__esModule: true,
|
||||
createNewTab: tabMock.createNewTab,
|
||||
}))
|
||||
|
||||
vi.mock("~/modules/i18n", () => ({
|
||||
__esModule: true,
|
||||
getI18n: () => (x: string) => x,
|
||||
@@ -72,8 +64,16 @@ describe("HistorySpotlightSearcherService", () => {
|
||||
y = historyMock.restEntries.pop()
|
||||
}
|
||||
|
||||
const container = new TestContainer()
|
||||
|
||||
const createNewTabFn = vi.fn()
|
||||
|
||||
container.bindMock(RESTTabService, {
|
||||
createNewTab: createNewTabFn,
|
||||
})
|
||||
|
||||
actionsMock.invokeAction.mockReset()
|
||||
tabMock.createNewTab.mockReset()
|
||||
createNewTabFn.mockReset()
|
||||
})
|
||||
|
||||
it("registers with the spotlight service upon initialization", async () => {
|
||||
|
||||
@@ -16,10 +16,6 @@ import {
|
||||
import IconFolder from "~icons/lucide/folder"
|
||||
import RESTRequestSpotlightEntry from "~/components/app/spotlight/entry/RESTRequest.vue"
|
||||
import GQLRequestSpotlightEntry from "~/components/app/spotlight/entry/GQLRequest.vue"
|
||||
import { createNewTab } from "~/helpers/rest/tab"
|
||||
import { createNewTab as createNewGQLTab } from "~/helpers/graphql/tab"
|
||||
import { getTabRefWithSaveContext } from "~/helpers/rest/tab"
|
||||
import { currentTabID } from "~/helpers/rest/tab"
|
||||
import {
|
||||
HoppCollection,
|
||||
HoppGQLRequest,
|
||||
@@ -27,6 +23,8 @@ import {
|
||||
} from "@hoppscotch/data"
|
||||
import { WorkspaceService } from "~/services/workspace.service"
|
||||
import { invokeAction } from "~/helpers/actions"
|
||||
import { RESTTabService } from "~/services/tab/rest"
|
||||
import { GQLTabService } from "~/services/tab/graphql"
|
||||
|
||||
/**
|
||||
* A spotlight searcher that searches through the user's collections
|
||||
@@ -44,6 +42,9 @@ export class CollectionsSpotlightSearcherService
|
||||
public searcherID = "collections"
|
||||
public searcherSectionTitle = this.t("collection.my_collections")
|
||||
|
||||
private readonly restTab = this.bind(RESTTabService)
|
||||
private readonly gqlTab = this.bind(GQLTabService)
|
||||
|
||||
private readonly spotlight = this.bind(SpotlightService)
|
||||
private readonly workspaceService = this.bind(WorkspaceService)
|
||||
|
||||
@@ -290,21 +291,21 @@ export class CollectionsSpotlightSearcherService
|
||||
})
|
||||
}
|
||||
|
||||
const possibleTab = getTabRefWithSaveContext({
|
||||
const possibleTab = this.restTab.getTabRefWithSaveContext({
|
||||
originLocation: "user-collection",
|
||||
folderPath: folderPath.join("/"),
|
||||
requestIndex: reqIndex,
|
||||
})
|
||||
|
||||
if (possibleTab) {
|
||||
currentTabID.value = possibleTab.value.id
|
||||
this.restTab.setActiveTab(possibleTab.value.id)
|
||||
} else {
|
||||
const req = this.getRESTFolderFromFolderPath(folderPath.join("/"))
|
||||
?.requests[reqIndex]
|
||||
|
||||
if (!req) return
|
||||
|
||||
createNewTab(
|
||||
this.restTab.createNewTab(
|
||||
{
|
||||
request: req,
|
||||
isDirty: false,
|
||||
@@ -326,7 +327,7 @@ export class CollectionsSpotlightSearcherService
|
||||
|
||||
if (!req) return
|
||||
|
||||
createNewGQLTab({
|
||||
this.gqlTab.createNewTab({
|
||||
saveContext: {
|
||||
originLocation: "user-collection",
|
||||
folderPath: folderPath.join("/"),
|
||||
|
||||
@@ -8,8 +8,7 @@ import {
|
||||
} from "./base/static.searcher"
|
||||
|
||||
import { useRoute } from "vue-router"
|
||||
import { RequestOptionTabs } from "~/components/http/RequestOptions.vue"
|
||||
import { currentActiveTab } from "~/helpers/rest/tab"
|
||||
import { RESTOptionTabs } from "~/components/http/RequestOptions.vue"
|
||||
import IconWindow from "~icons/lucide/app-window"
|
||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||
import IconCode2 from "~icons/lucide/code-2"
|
||||
@@ -20,6 +19,7 @@ import IconPlay from "~icons/lucide/play"
|
||||
import IconRotateCCW from "~icons/lucide/rotate-ccw"
|
||||
import IconSave from "~icons/lucide/save"
|
||||
import { GQLOptionTabs } from "~/components/graphql/RequestOptions.vue"
|
||||
import { RESTTabService } from "~/services/tab/rest"
|
||||
|
||||
type Doc = {
|
||||
text: string | string[]
|
||||
@@ -43,6 +43,7 @@ export class RequestSpotlightSearcherService extends StaticSpotlightSearcherServ
|
||||
public searcherSectionTitle = this.t("shortcut.request.title")
|
||||
|
||||
private readonly spotlight = this.bind(SpotlightService)
|
||||
private readonly restTab = this.bind(RESTTabService)
|
||||
|
||||
private route = useRoute()
|
||||
private isRESTPage = computed(() => this.route.name === "index")
|
||||
@@ -247,7 +248,7 @@ export class RequestSpotlightSearcherService extends StaticSpotlightSearcherServ
|
||||
}
|
||||
}
|
||||
|
||||
private openRequestTab(tab: RequestOptionTabs | GQLOptionTabs): void {
|
||||
private openRequestTab(tab: RESTOptionTabs | GQLOptionTabs): void {
|
||||
invokeAction("request.open-tab", {
|
||||
tab,
|
||||
})
|
||||
@@ -267,7 +268,7 @@ export class RequestSpotlightSearcherService extends StaticSpotlightSearcherServ
|
||||
case "save_to_collections":
|
||||
invokeAction("request.save-as", {
|
||||
requestType: "rest",
|
||||
request: currentActiveTab.value?.document.request,
|
||||
request: this.restTab.currentActiveTab.value?.document.request,
|
||||
})
|
||||
break
|
||||
case "save_request":
|
||||
|
||||
@@ -12,8 +12,8 @@ import IconCopyPlus from "~icons/lucide/copy-plus"
|
||||
import IconXCircle from "~icons/lucide/x-circle"
|
||||
import IconXSquare from "~icons/lucide/x-square"
|
||||
import { invokeAction } from "~/helpers/actions"
|
||||
import { getActiveTabs as getRESTActiveTabs } from "~/helpers/rest/tab"
|
||||
import { getActiveTabs as getGQLActiveTabs } from "~/helpers/graphql/tab"
|
||||
import { RESTTabService } from "~/services/tab/rest"
|
||||
import { GQLTabService } from "~/services/tab/graphql"
|
||||
|
||||
type Doc = {
|
||||
text: string | string[]
|
||||
@@ -42,12 +42,14 @@ export class TabSpotlightSearcherService extends StaticSpotlightSearcherService<
|
||||
private showAction = computed(
|
||||
() => this.route.name === "index" || this.route.name === "graphql"
|
||||
)
|
||||
private gqlActiveTabs = getGQLActiveTabs()
|
||||
private restActiveTabs = getRESTActiveTabs()
|
||||
|
||||
private readonly restTab = this.bind(RESTTabService)
|
||||
private readonly gqlTab = this.bind(GQLTabService)
|
||||
|
||||
private isOnlyTab = computed(() =>
|
||||
this.route.name === "graphql"
|
||||
? this.gqlActiveTabs.value.length === 1
|
||||
: this.restActiveTabs.value.length === 1
|
||||
? this.gqlTab.getActiveTabs().value.length === 1
|
||||
: this.restTab.getActiveTabs().value.length === 1
|
||||
)
|
||||
|
||||
private documents: Record<string, Doc> = reactive({
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { TestContainer } from "dioc/testing"
|
||||
import { TabService } from "../tab"
|
||||
import { reactive } from "vue"
|
||||
|
||||
class MockTabService extends TabService<{ request: string }> {
|
||||
public static readonly ID = "MOCK_TAB_SERVICE"
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.tabMap = reactive(
|
||||
new Map([
|
||||
[
|
||||
"test",
|
||||
{
|
||||
id: "test",
|
||||
document: {
|
||||
request: "test request",
|
||||
},
|
||||
},
|
||||
],
|
||||
])
|
||||
)
|
||||
|
||||
this.watchCurrentTabID()
|
||||
}
|
||||
}
|
||||
|
||||
describe("TabService", () => {
|
||||
it("initially only one tab is defined", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const service = container.bind(MockTabService)
|
||||
|
||||
expect(service.getActiveTabs().value.length).toEqual(1)
|
||||
})
|
||||
|
||||
it("initially the only tab is the active tab", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const service = container.bind(MockTabService)
|
||||
|
||||
expect(service.getActiveTab()).not.toBeNull()
|
||||
})
|
||||
|
||||
it("initially active tab id is test", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const service = container.bind(MockTabService)
|
||||
|
||||
expect(service.getActiveTab()?.id).toEqual("test")
|
||||
})
|
||||
|
||||
it("add new tab", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const service = container.bind(MockTabService)
|
||||
|
||||
service.createNewTab({
|
||||
request: "new request",
|
||||
})
|
||||
|
||||
expect(service.getActiveTabs().value.length).toEqual(2)
|
||||
})
|
||||
|
||||
it("get active tab", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const service = container.bind(MockTabService)
|
||||
|
||||
console.log(service.getActiveTab())
|
||||
|
||||
expect(service.getActiveTab()?.id).toEqual("test")
|
||||
})
|
||||
|
||||
it("sort tabs", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const service = container.bind(MockTabService)
|
||||
|
||||
const currentOrder = service.updateTabOrdering(1, 0)
|
||||
|
||||
expect(currentOrder[1]).toEqual("test")
|
||||
})
|
||||
|
||||
it("update tab", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const service = container.bind(MockTabService)
|
||||
|
||||
service.updateTab({
|
||||
id: service.currentTabID.value,
|
||||
document: {
|
||||
request: "updated request",
|
||||
},
|
||||
})
|
||||
|
||||
expect(service.getActiveTab()?.document.request).toEqual("updated request")
|
||||
})
|
||||
|
||||
it("set new active tab", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const service = container.bind(MockTabService)
|
||||
|
||||
service.setActiveTab("test")
|
||||
|
||||
expect(service.getActiveTab()?.id).toEqual("test")
|
||||
})
|
||||
|
||||
it("close other tabs", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const service = container.bind(MockTabService)
|
||||
|
||||
service.closeOtherTabs("test")
|
||||
|
||||
expect(service.getActiveTabs().value.length).toEqual(1)
|
||||
})
|
||||
|
||||
it("close tab", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const service = container.bind(MockTabService)
|
||||
|
||||
service.createNewTab({
|
||||
request: "new request",
|
||||
})
|
||||
|
||||
expect(service.getActiveTabs().value.length).toEqual(2)
|
||||
|
||||
service.closeTab("test")
|
||||
|
||||
expect(service.getActiveTabs().value.length).toEqual(1)
|
||||
})
|
||||
})
|
||||
66
packages/hoppscotch-common/src/services/tab/graphql.ts
Normal file
66
packages/hoppscotch-common/src/services/tab/graphql.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { isEqual } from "lodash-es"
|
||||
import { getDefaultGQLRequest } from "~/helpers/graphql/default"
|
||||
import { HoppGQLDocument, HoppGQLSaveContext } from "~/helpers/graphql/document"
|
||||
import { TabService } from "./tab"
|
||||
import { computed } from "vue"
|
||||
|
||||
export class GQLTabService extends TabService<HoppGQLDocument> {
|
||||
public static readonly ID = "GQL_TAB_SERVICE"
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.tabMap.set("test", {
|
||||
id: "test",
|
||||
document: {
|
||||
request: getDefaultGQLRequest(),
|
||||
isDirty: false,
|
||||
optionTabPreference: "query",
|
||||
},
|
||||
})
|
||||
|
||||
this.watchCurrentTabID()
|
||||
}
|
||||
|
||||
// override persistableTabState to remove response from the document
|
||||
public override persistableTabState = computed(() => ({
|
||||
lastActiveTabID: this.currentTabID.value,
|
||||
orderedDocs: this.tabOrdering.value.map((tabID) => {
|
||||
const tab = this.tabMap.get(tabID)! // tab ordering is guaranteed to have value for this key
|
||||
return {
|
||||
tabID: tab.id,
|
||||
doc: {
|
||||
...tab.document,
|
||||
response: null,
|
||||
},
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
||||
public getTabRefWithSaveContext(ctx: HoppGQLSaveContext) {
|
||||
for (const tab of this.tabMap.values()) {
|
||||
// For `team-collection` request id can be considered unique
|
||||
if (ctx?.originLocation === "team-collection") {
|
||||
if (
|
||||
tab.document.saveContext?.originLocation === "team-collection" &&
|
||||
tab.document.saveContext.requestID === ctx.requestID
|
||||
) {
|
||||
return this.getTabRef(tab.id)
|
||||
}
|
||||
} else if (isEqual(ctx, tab.document.saveContext))
|
||||
return this.getTabRef(tab.id)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
public getDirtyTabsCount() {
|
||||
let count = 0
|
||||
|
||||
for (const tab of this.tabMap.values()) {
|
||||
if (tab.document.isDirty) count++
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
}
|
||||
116
packages/hoppscotch-common/src/services/tab/index.ts
Normal file
116
packages/hoppscotch-common/src/services/tab/index.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { ComputedRef, WritableComputedRef } from "vue"
|
||||
|
||||
/**
|
||||
* Represents a tab in HoppScotch.
|
||||
* @template Doc The type of the document associated with the tab.
|
||||
*/
|
||||
export type HoppTab<Doc> = {
|
||||
/** The unique identifier of the tab. */
|
||||
id: string
|
||||
/** The document associated with the tab. */
|
||||
document: Doc
|
||||
}
|
||||
|
||||
export type PersistableTabState<Doc> = {
|
||||
lastActiveTabID: string
|
||||
orderedDocs: Array<{
|
||||
tabID: string
|
||||
doc: Doc
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a service for managing tabs with documents.
|
||||
* @template Doc - The type of document associated with each tab.
|
||||
*/
|
||||
export interface TabService<Doc> {
|
||||
/**
|
||||
* Gets the current active tab.
|
||||
*/
|
||||
currentActiveTab: ComputedRef<HoppTab<Doc>>
|
||||
|
||||
/**
|
||||
* Creates a new tab with the given document and sets it as the active tab.
|
||||
* @param document - The document to associate with the new tab.
|
||||
* @returns The newly created tab.
|
||||
*/
|
||||
createNewTab(document: Doc): HoppTab<Doc>
|
||||
|
||||
/**
|
||||
* Gets an array of all tabs.
|
||||
* @returns An array of all tabs.
|
||||
*/
|
||||
getTabs(): HoppTab<Doc>[]
|
||||
|
||||
/**
|
||||
* Gets the currently active tab.
|
||||
* @returns The active tab or null if no tab is active.
|
||||
*/
|
||||
getActiveTab(): HoppTab<Doc> | null
|
||||
|
||||
/**
|
||||
* Sets the active tab by its ID.
|
||||
* @param tabID - The ID of the tab to set as active.
|
||||
*/
|
||||
setActiveTab(tabID: string): void
|
||||
|
||||
/**
|
||||
* Loads tabs and their ordering from a persisted state.
|
||||
* @param data - The persisted tab state to load.
|
||||
*/
|
||||
loadTabsFromPersistedState(data: PersistableTabState<Doc>): void
|
||||
|
||||
/**
|
||||
* Gets a read-only computed reference to the active tabs.
|
||||
* @returns A computed reference to the active tabs.
|
||||
*/
|
||||
getActiveTabs(): Readonly<ComputedRef<HoppTab<Doc>[]>>
|
||||
|
||||
/**
|
||||
* Gets a computed reference to a specific tab by its ID.
|
||||
* @param tabID - The ID of the tab to retrieve.
|
||||
* @returns A computed reference to the specified tab.
|
||||
* @throws An error if the tab with the specified ID does not exist.
|
||||
*/
|
||||
getTabRef(tabID: string): WritableComputedRef<HoppTab<Doc>>
|
||||
|
||||
/**
|
||||
* Updates the properties of a tab.
|
||||
* @param tabUpdate - The updated tab object.
|
||||
*/
|
||||
updateTab(tabUpdate: HoppTab<Doc>): void
|
||||
|
||||
/**
|
||||
* Updates the ordering of tabs by moving a tab from one index to another.
|
||||
* @param fromIndex - The current index of the tab to move.
|
||||
* @param toIndex - The target index where the tab should be moved to.
|
||||
*/
|
||||
updateTabOrdering(fromIndex: number, toIndex: number): void
|
||||
|
||||
/**
|
||||
* Closes the tab with the specified ID.
|
||||
* @param tabID - The ID of the tab to close.
|
||||
*/
|
||||
closeTab(tabID: string): void
|
||||
|
||||
/**
|
||||
* Closes all tabs except the one with the specified ID.
|
||||
* @param tabID - The ID of the tab to keep open.
|
||||
*/
|
||||
closeOtherTabs(tabID: string): void
|
||||
|
||||
/**
|
||||
* Gets a computed reference to a persistable tab state.
|
||||
* @returns A computed reference to a persistable tab state object.
|
||||
*/
|
||||
persistableTabState: ComputedRef<PersistableTabState<Doc>>
|
||||
|
||||
/**
|
||||
* Gets computed references to tabs that match a specified condition.
|
||||
* @param func - A function that defines the condition for selecting tabs.
|
||||
* @returns An array of computed references to matching tabs.
|
||||
*/
|
||||
getTabsRefTo(
|
||||
func: (tab: HoppTab<Doc>) => boolean
|
||||
): WritableComputedRef<HoppTab<Doc>>[]
|
||||
}
|
||||
67
packages/hoppscotch-common/src/services/tab/rest.ts
Normal file
67
packages/hoppscotch-common/src/services/tab/rest.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { isEqual } from "lodash-es"
|
||||
import { computed } from "vue"
|
||||
import { getDefaultRESTRequest } from "~/helpers/rest/default"
|
||||
import { HoppRESTDocument, HoppRESTSaveContext } from "~/helpers/rest/document"
|
||||
import { TabService } from "./tab"
|
||||
|
||||
export class RESTTabService extends TabService<HoppRESTDocument> {
|
||||
public static readonly ID = "REST_TAB_SERVICE"
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.tabMap.set("test", {
|
||||
id: "test",
|
||||
document: {
|
||||
request: getDefaultRESTRequest(),
|
||||
isDirty: false,
|
||||
optionTabPreference: "params",
|
||||
},
|
||||
})
|
||||
|
||||
this.watchCurrentTabID()
|
||||
}
|
||||
|
||||
// override persistableTabState to remove response from the document
|
||||
public override persistableTabState = computed(() => ({
|
||||
lastActiveTabID: this.currentTabID.value,
|
||||
orderedDocs: this.tabOrdering.value.map((tabID) => {
|
||||
const tab = this.tabMap.get(tabID)! // tab ordering is guaranteed to have value for this key
|
||||
return {
|
||||
tabID: tab.id,
|
||||
doc: {
|
||||
...tab.document,
|
||||
response: null,
|
||||
},
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
||||
public getTabRefWithSaveContext(ctx: HoppRESTSaveContext) {
|
||||
for (const tab of this.tabMap.values()) {
|
||||
// For `team-collection` request id can be considered unique
|
||||
if (ctx?.originLocation === "team-collection") {
|
||||
if (
|
||||
tab.document.saveContext?.originLocation === "team-collection" &&
|
||||
tab.document.saveContext.requestID === ctx.requestID
|
||||
) {
|
||||
return this.getTabRef(tab.id)
|
||||
}
|
||||
} else if (isEqual(ctx, tab.document.saveContext)) {
|
||||
return this.getTabRef(tab.id)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
public getDirtyTabsCount() {
|
||||
let count = 0
|
||||
|
||||
for (const tab of this.tabMap.values()) {
|
||||
if (tab.document.isDirty) count++
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
}
|
||||
207
packages/hoppscotch-common/src/services/tab/tab.ts
Normal file
207
packages/hoppscotch-common/src/services/tab/tab.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { refWithControl } from "@vueuse/core"
|
||||
import { Service } from "dioc"
|
||||
import { v4 as uuidV4 } from "uuid"
|
||||
import {
|
||||
ComputedRef,
|
||||
computed,
|
||||
nextTick,
|
||||
reactive,
|
||||
ref,
|
||||
shallowReadonly,
|
||||
watch,
|
||||
} from "vue"
|
||||
import {
|
||||
HoppTab,
|
||||
PersistableTabState,
|
||||
TabService as TabServiceInterface,
|
||||
} from "."
|
||||
|
||||
export abstract class TabService<Doc>
|
||||
extends Service
|
||||
implements TabServiceInterface<Doc>
|
||||
{
|
||||
protected tabMap = reactive(new Map<string, HoppTab<Doc>>())
|
||||
protected tabOrdering = ref<string[]>(["test"])
|
||||
|
||||
public currentTabID = refWithControl("test", {
|
||||
onBeforeChange: (newTabID) => {
|
||||
if (!newTabID || !this.tabMap.has(newTabID)) {
|
||||
console.warn(
|
||||
`Tried to set current tab id to an invalid value. (value: ${newTabID})`
|
||||
)
|
||||
|
||||
// Don't allow change
|
||||
return false
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
public currentActiveTab = computed(
|
||||
() => this.tabMap.get(this.currentTabID.value)!
|
||||
) // Guaranteed to not be undefined
|
||||
|
||||
protected watchCurrentTabID() {
|
||||
watch(
|
||||
this.tabOrdering,
|
||||
(newOrdering) => {
|
||||
if (
|
||||
!this.currentTabID.value ||
|
||||
!newOrdering.includes(this.currentTabID.value)
|
||||
) {
|
||||
this.setActiveTab(newOrdering[newOrdering.length - 1]) // newOrdering should always be non-empty
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
}
|
||||
|
||||
public createNewTab(document: Doc, switchToIt = true): HoppTab<Doc> {
|
||||
const id = this.generateNewTabID()
|
||||
|
||||
const tab: HoppTab<Doc> = { id, document }
|
||||
|
||||
this.tabMap.set(id, tab)
|
||||
this.tabOrdering.value.push(id)
|
||||
|
||||
if (switchToIt) {
|
||||
this.setActiveTab(id)
|
||||
}
|
||||
|
||||
return tab
|
||||
}
|
||||
|
||||
public getTabs(): HoppTab<Doc>[] {
|
||||
return Array.from(this.tabMap.values())
|
||||
}
|
||||
|
||||
public getActiveTab(): HoppTab<Doc> | null {
|
||||
return this.tabMap.get(this.currentTabID.value) ?? null
|
||||
}
|
||||
|
||||
public setActiveTab(tabID: string): void {
|
||||
this.currentTabID.value = tabID
|
||||
}
|
||||
|
||||
public loadTabsFromPersistedState(data: PersistableTabState<Doc>): void {
|
||||
if (data) {
|
||||
this.tabMap.clear()
|
||||
this.tabOrdering.value = []
|
||||
|
||||
for (const doc of data.orderedDocs) {
|
||||
this.tabMap.set(doc.tabID, {
|
||||
id: doc.tabID,
|
||||
document: doc.doc,
|
||||
})
|
||||
|
||||
this.tabOrdering.value.push(doc.tabID)
|
||||
}
|
||||
|
||||
this.setActiveTab(data.lastActiveTabID)
|
||||
}
|
||||
}
|
||||
|
||||
public getActiveTabs(): Readonly<ComputedRef<HoppTab<Doc>[]>> {
|
||||
return shallowReadonly(
|
||||
computed(() => this.tabOrdering.value.map((x) => this.tabMap.get(x)!))
|
||||
)
|
||||
}
|
||||
|
||||
public getTabRef(tabID: string) {
|
||||
return computed({
|
||||
get: () => {
|
||||
const result = this.tabMap.get(tabID)
|
||||
|
||||
if (result === undefined) throw new Error(`Invalid tab id: ${tabID}`)
|
||||
|
||||
return result
|
||||
},
|
||||
set: (value) => {
|
||||
return this.tabMap.set(tabID, value)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
public updateTab(tabUpdate: HoppTab<Doc>) {
|
||||
if (!this.tabMap.has(tabUpdate.id)) {
|
||||
console.warn(
|
||||
`Cannot update tab as tab with that tab id does not exist (id: ${tabUpdate.id})`
|
||||
)
|
||||
}
|
||||
|
||||
this.tabMap.set(tabUpdate.id, tabUpdate)
|
||||
}
|
||||
|
||||
public updateTabOrdering(fromIndex: number, toIndex: number) {
|
||||
this.tabOrdering.value.splice(
|
||||
toIndex,
|
||||
0,
|
||||
this.tabOrdering.value.splice(fromIndex, 1)[0]
|
||||
)
|
||||
|
||||
return this.tabOrdering.value
|
||||
}
|
||||
|
||||
public closeTab(tabID: string) {
|
||||
if (!this.tabMap.has(tabID)) {
|
||||
console.warn(
|
||||
`Tried to close a tab which does not exist (tab id: ${tabID})`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.tabOrdering.value.length === 1) {
|
||||
console.warn(
|
||||
`Tried to close the only tab open, which is not allowed. (tab id: ${tabID})`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
this.tabOrdering.value.splice(this.tabOrdering.value.indexOf(tabID), 1)
|
||||
|
||||
nextTick(() => {
|
||||
this.tabMap.delete(tabID)
|
||||
})
|
||||
}
|
||||
|
||||
public closeOtherTabs(tabID: string) {
|
||||
if (!this.tabMap.has(tabID)) {
|
||||
console.warn(
|
||||
`The tab to close other tabs does not exist (tab id: ${tabID})`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
this.tabOrdering.value = [tabID]
|
||||
|
||||
this.tabMap.forEach((_, id) => {
|
||||
if (id !== tabID) this.tabMap.delete(id)
|
||||
})
|
||||
|
||||
this.currentTabID.value = tabID
|
||||
}
|
||||
|
||||
public persistableTabState = computed<PersistableTabState<Doc>>(() => ({
|
||||
lastActiveTabID: this.currentTabID.value,
|
||||
orderedDocs: this.tabOrdering.value.map((tabID) => {
|
||||
const tab = this.tabMap.get(tabID)! // tab ordering is guaranteed to have value for this key
|
||||
return {
|
||||
tabID: tab.id,
|
||||
doc: tab.document,
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
||||
public getTabsRefTo(func: (tab: HoppTab<Doc>) => boolean) {
|
||||
return Array.from(this.tabMap.values())
|
||||
.filter(func)
|
||||
.map((tab) => this.getTabRef(tab.id))
|
||||
}
|
||||
|
||||
private generateNewTabID() {
|
||||
while (true) {
|
||||
const id = uuidV4()
|
||||
|
||||
if (!this.tabMap.has(id)) return id
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user