refactor: keep tab dirty status logic at the page level
This commit is contained in:
@@ -772,27 +772,6 @@ const onRemoveRootCollection = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
const activeTabs = tabs.getActiveTabs()
|
||||
|
||||
for (const tab of activeTabs.value) {
|
||||
if (
|
||||
tab.document.saveContext?.originLocation === "workspace-user-collection"
|
||||
) {
|
||||
const requestHandle = tab.document.saveContext?.requestHandle as
|
||||
| HandleRef<WorkspaceRequest>["value"]
|
||||
| undefined
|
||||
|
||||
if (requestHandle?.type === "invalid") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (requestHandle!.data.requestID.startsWith(collectionIndexPath)) {
|
||||
tab.document.saveContext = null
|
||||
tab.document.isDirty = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(t("state.deleted"))
|
||||
displayConfirmModal(false)
|
||||
}
|
||||
@@ -1062,28 +1041,6 @@ const onRemoveChildCollection = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Tab holding a request under the collection should be aware of the parent collection invalidation and toggle the dirty state
|
||||
const activeTabs = tabs.getActiveTabs()
|
||||
|
||||
for (const tab of activeTabs.value) {
|
||||
if (
|
||||
tab.document.saveContext?.originLocation === "workspace-user-collection"
|
||||
) {
|
||||
const requestHandle = tab.document.saveContext?.requestHandle as
|
||||
| HandleRef<WorkspaceRequest>["value"]
|
||||
| undefined
|
||||
|
||||
if (requestHandle?.type === "invalid") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (requestHandle!.data.requestID.startsWith(parentCollectionIndexPath)) {
|
||||
tab.document.saveContext = null
|
||||
tab.document.isDirty = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(t("state.deleted"))
|
||||
displayConfirmModal(false)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<HoppSmartWindow
|
||||
v-for="tab in activeTabs"
|
||||
:id="tab.id"
|
||||
:key="`${tab.id}-${tab.document.isDirty}`"
|
||||
:key="tab.id"
|
||||
:label="tab.document.request.name"
|
||||
:is-removable="activeTabs.length > 1"
|
||||
:close-visibility="'hover'"
|
||||
@@ -26,12 +26,11 @@
|
||||
@close-tab="removeTab(tab.id)"
|
||||
@close-other-tabs="closeOtherTabsAction(tab.id)"
|
||||
@duplicate-tab="duplicateTab(tab.id)"
|
||||
@share-tab-request="shareTabRequest(tab.id)"
|
||||
/>
|
||||
</template>
|
||||
<template #suffix>
|
||||
<span
|
||||
v-if="tab.document.isDirty"
|
||||
v-if="getTabDirtyStatus(tab)"
|
||||
class="flex w-4 items-center justify-center text-secondary group-hover:hidden"
|
||||
>
|
||||
<svg
|
||||
@@ -64,6 +63,13 @@
|
||||
@submit="renameReqName"
|
||||
@hide-modal="showRenamingReqNameModal = false"
|
||||
/>
|
||||
<HoppSmartConfirmModal
|
||||
:show="confirmingCloseForTabID !== null"
|
||||
:confirm="t('modal.close_unsaved_tab')"
|
||||
:title="t('confirm.save_unsaved_tab')"
|
||||
@hide-modal="onCloseConfirmSaveTab"
|
||||
@resolve="onResolveConfirmSaveTab"
|
||||
/>
|
||||
<HoppSmartConfirmModal
|
||||
:show="confirmingCloseAllTabs"
|
||||
:confirm="t('modal.close_unsaved_tab')"
|
||||
@@ -71,36 +77,6 @@
|
||||
@hide-modal="confirmingCloseAllTabs = false"
|
||||
@resolve="onResolveConfirmCloseAllTabs"
|
||||
/>
|
||||
<HoppSmartModal
|
||||
v-if="confirmingCloseForTabID !== null"
|
||||
dialog
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:title="t('modal.close_unsaved_tab')"
|
||||
@close="confirmingCloseForTabID = null"
|
||||
>
|
||||
<template #body>
|
||||
<div class="text-center">
|
||||
{{ t("confirm.save_unsaved_tab") }}
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span class="flex space-x-2">
|
||||
<HoppButtonPrimary
|
||||
v-focus
|
||||
:label="t?.('action.yes')"
|
||||
outline
|
||||
@click="onResolveConfirmSaveTab"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
:label="t?.('action.no')"
|
||||
filled
|
||||
outline
|
||||
@click="onCloseConfirmSaveTab"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</HoppSmartModal>
|
||||
<CollectionsSaveRequest
|
||||
v-if="savingRequest"
|
||||
mode="rest"
|
||||
@@ -120,23 +96,37 @@
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { safelyExtractRESTRequest } from "@hoppscotch/data"
|
||||
import { watchDebounced } from "@vueuse/core"
|
||||
import { useService } from "dioc/vue"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import { onMounted, ref } from "vue"
|
||||
import {
|
||||
BehaviorSubject,
|
||||
EMPTY,
|
||||
Subscription,
|
||||
audit,
|
||||
combineLatest,
|
||||
from,
|
||||
map,
|
||||
} from "rxjs"
|
||||
import { onBeforeUnmount, onMounted, ref } from "vue"
|
||||
import { useRoute } from "vue-router"
|
||||
import { onLoggedIn } from "~/composables/auth"
|
||||
import { useReadonlyStream } from "~/composables/stream"
|
||||
import { useToast } from "~/composables/toast"
|
||||
import { translateExtURLParams } from "~/helpers/RESTExtURLParams"
|
||||
import { defineActionHandler, invokeAction } from "~/helpers/actions"
|
||||
import { getDefaultRESTRequest } from "~/helpers/rest/default"
|
||||
import { HoppRESTDocument } from "~/helpers/rest/document"
|
||||
import {
|
||||
changeCurrentSyncStatus,
|
||||
currentSyncingStatus$,
|
||||
} from "~/newstore/syncing"
|
||||
import { platform } from "~/platform"
|
||||
import { InspectionService } from "~/services/inspection"
|
||||
import { EnvironmentInspectorService } from "~/services/inspection/inspectors/environment.inspector"
|
||||
import { HeaderInspectorService } from "~/services/inspection/inspectors/header.inspector"
|
||||
import { ResponseInspectorService } from "~/services/inspection/inspectors/response.inspector"
|
||||
import { HandleRef } from "~/services/new-workspace/handle"
|
||||
import { WorkspaceRequest } from "~/services/new-workspace/workspace"
|
||||
import { HoppTab } from "~/services/tab"
|
||||
import { HoppTab, PersistableTabState } from "~/services/tab"
|
||||
import { RESTTabService } from "~/services/tab/rest"
|
||||
|
||||
const savingRequest = ref(false)
|
||||
@@ -149,16 +139,12 @@ const exceptedTabID = ref<string | null>(null)
|
||||
const renameTabID = ref<string | null>(null)
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const tabs = useService(RESTTabService)
|
||||
|
||||
const currentTabID = tabs.currentTabID
|
||||
|
||||
const currentUser = useReadonlyStream(
|
||||
platform.auth.getCurrentUserStream(),
|
||||
platform.auth.getCurrentUser()
|
||||
)
|
||||
|
||||
type PopupDetails = {
|
||||
show: boolean
|
||||
position: {
|
||||
@@ -179,6 +165,12 @@ const contextMenu = ref<PopupDetails>({
|
||||
|
||||
const activeTabs = tabs.getActiveTabs()
|
||||
|
||||
const confirmSync = useReadonlyStream(currentSyncingStatus$, {
|
||||
isInitialSync: false,
|
||||
shouldSync: true,
|
||||
})
|
||||
const tabStateForSync = ref<PersistableTabState<HoppRESTDocument> | null>(null)
|
||||
|
||||
function bindRequestToURLParams() {
|
||||
const route = useRoute()
|
||||
// Get URL parameters and set that as the request
|
||||
@@ -218,7 +210,7 @@ const inspectionService = useService(InspectionService)
|
||||
const removeTab = (tabID: string) => {
|
||||
const tabState = tabs.getTabRef(tabID).value
|
||||
|
||||
if (tabState.document.isDirty) {
|
||||
if (getTabDirtyStatus(tabState)) {
|
||||
confirmingCloseForTabID.value = tabID
|
||||
} else {
|
||||
tabs.closeTab(tabState.id)
|
||||
@@ -227,8 +219,10 @@ const removeTab = (tabID: string) => {
|
||||
}
|
||||
|
||||
const closeOtherTabsAction = (tabID: string) => {
|
||||
const isTabDirty = tabs.getTabRef(tabID).value?.document.isDirty
|
||||
const dirtyTabCount = tabs.getDirtyTabsCount()
|
||||
|
||||
const isTabDirty = getTabDirtyStatus(tabs.getTabRef(tabID).value)
|
||||
|
||||
// If current tab is dirty, so we need to subtract 1 from the dirty tab count
|
||||
const balanceDirtyTabCount = isTabDirty ? dirtyTabCount - 1 : dirtyTabCount
|
||||
|
||||
@@ -301,12 +295,7 @@ const onResolveConfirmSaveTab = () => {
|
||||
if (
|
||||
!saveContext ||
|
||||
(saveContext.originLocation === "workspace-user-collection" &&
|
||||
// `requestHandle` gets unwrapped here
|
||||
(
|
||||
saveContext.requestHandle as
|
||||
| HandleRef<WorkspaceRequest>["value"]
|
||||
| undefined
|
||||
)?.type === "invalid")
|
||||
saveContext.requestHandle?.value.type === "invalid")
|
||||
) {
|
||||
return (savingRequest.value = true)
|
||||
}
|
||||
@@ -330,17 +319,120 @@ const onSaveModalClose = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const shareTabRequest = (tabID: string) => {
|
||||
const tab = tabs.getTabRef(tabID)
|
||||
if (tab.value) {
|
||||
if (currentUser.value) {
|
||||
invokeAction("share.request", {
|
||||
request: tab.value.document.request,
|
||||
})
|
||||
} else {
|
||||
invokeAction("modals.login.toggle")
|
||||
}
|
||||
const syncTabState = () => {
|
||||
if (tabStateForSync.value)
|
||||
tabs.loadTabsFromPersistedState(tabStateForSync.value)
|
||||
}
|
||||
|
||||
const getTabDirtyStatus = (tab: HoppTab<HoppRESTDocument>) => {
|
||||
if (tab.document.isDirty) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
tab.document.saveContext?.originLocation === "workspace-user-collection" &&
|
||||
tab.document.saveContext.requestHandle?.value.type === "invalid"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs sync of the REST Tab session with Firestore.
|
||||
*
|
||||
* @returns A subscription to the sync observable stream.
|
||||
* Unsubscribe to stop syncing.
|
||||
*/
|
||||
function startTabStateSync(): Subscription {
|
||||
const currentUser$ = platform.auth.getCurrentUserStream()
|
||||
const tabState$ =
|
||||
new BehaviorSubject<PersistableTabState<HoppRESTDocument> | null>(null)
|
||||
|
||||
watchDebounced(
|
||||
tabs.persistableTabState,
|
||||
(state) => {
|
||||
tabState$.next(state)
|
||||
},
|
||||
{ debounce: 500, deep: true }
|
||||
)
|
||||
|
||||
const sub = combineLatest([currentUser$, tabState$])
|
||||
.pipe(
|
||||
map(([user, tabState]) =>
|
||||
user && tabState
|
||||
? from(platform.sync.tabState.writeCurrentTabState(user, tabState))
|
||||
: EMPTY
|
||||
),
|
||||
audit((x) => x)
|
||||
)
|
||||
.subscribe(() => {
|
||||
// NOTE: This subscription should be kept
|
||||
})
|
||||
|
||||
return sub
|
||||
}
|
||||
|
||||
const showSyncToast = () => {
|
||||
toast.show(t("confirm.sync"), {
|
||||
duration: 0,
|
||||
action: [
|
||||
{
|
||||
text: `${t("action.yes")}`,
|
||||
onClick: (_, toastObject) => {
|
||||
syncTabState()
|
||||
changeCurrentSyncStatus({
|
||||
isInitialSync: true,
|
||||
shouldSync: true,
|
||||
})
|
||||
toastObject.goAway(0)
|
||||
},
|
||||
},
|
||||
{
|
||||
text: `${t("action.no")}`,
|
||||
onClick: (_, toastObject) => {
|
||||
changeCurrentSyncStatus({
|
||||
isInitialSync: true,
|
||||
shouldSync: false,
|
||||
})
|
||||
toastObject.goAway(0)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function setupTabStateSync() {
|
||||
const route = useRoute()
|
||||
|
||||
// Subscription to request sync
|
||||
let sub: Subscription | null = null
|
||||
|
||||
// Load request on login resolve and start sync
|
||||
onLoggedIn(async () => {
|
||||
if (
|
||||
Object.keys(route.query).length === 0 &&
|
||||
!(route.query.code || route.query.error)
|
||||
) {
|
||||
const tabStateFromSync =
|
||||
await platform.sync.tabState.loadTabStateFromSync()
|
||||
|
||||
if (tabStateFromSync && !confirmSync.value.isInitialSync) {
|
||||
tabStateForSync.value = tabStateFromSync
|
||||
showSyncToast()
|
||||
// Have to set isInitialSync to true here because the toast is shown
|
||||
// and the user does not click on any of the actions
|
||||
changeCurrentSyncStatus({
|
||||
isInitialSync: true,
|
||||
shouldSync: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sub = startTabStateSync()
|
||||
})
|
||||
|
||||
// Stop subscription to stop syncing
|
||||
onBeforeUnmount(() => {
|
||||
sub?.unsubscribe()
|
||||
})
|
||||
}
|
||||
|
||||
defineActionHandler("contextmenu.open", ({ position, text }) => {
|
||||
@@ -359,6 +451,7 @@ defineActionHandler("contextmenu.open", ({ position, text }) => {
|
||||
}
|
||||
})
|
||||
|
||||
setupTabStateSync()
|
||||
bindRequestToURLParams()
|
||||
|
||||
defineActionHandler("rest.request.open", ({ doc }) => {
|
||||
@@ -384,5 +477,3 @@ for (const inspectorDef of platform.additionalInspectors ?? []) {
|
||||
useService(inspectorDef.service)
|
||||
}
|
||||
</script>
|
||||
import { HandleRef } from "~/services/new-workspace/handle" import {
|
||||
WorkspaceRequest } from "~/services/new-workspace/workspace"
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
computed,
|
||||
effectScope,
|
||||
markRaw,
|
||||
nextTick,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch,
|
||||
@@ -302,15 +303,18 @@ export class PersonalWorkspaceProviderService
|
||||
)
|
||||
}
|
||||
|
||||
for (const handle of this.issuedHandles) {
|
||||
for (const [idx, handle] of this.issuedHandles.entries()) {
|
||||
if (handle.value.type === "invalid") continue
|
||||
|
||||
if ("requestID" in handle.value.data) {
|
||||
if (handle.value.data.requestID.startsWith(collectionID)) {
|
||||
handle.value = {
|
||||
type: "invalid",
|
||||
reason: "REQUEST_INVALIDATED",
|
||||
}
|
||||
// @ts-expect-error - We're deleting the data to invalidate the handle
|
||||
delete this.issuedHandles[idx].value.data
|
||||
|
||||
this.issuedHandles[idx].value.type = "invalid"
|
||||
|
||||
// @ts-expect-error - Setting the handle invalidation reason
|
||||
this.issuedHandles[idx].value.reason = "REQUEST_INVALIDATED"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -403,15 +407,18 @@ export class PersonalWorkspaceProviderService
|
||||
|
||||
removeRESTRequest(collectionID, requestIndex, requestToRemove?.id)
|
||||
|
||||
for (const handle of this.issuedHandles) {
|
||||
for (const [idx, handle] of this.issuedHandles.entries()) {
|
||||
if (handle.value.type === "invalid") continue
|
||||
|
||||
if ("requestID" in handle.value.data) {
|
||||
if (handle.value.data.requestID === requestID) {
|
||||
handle.value = {
|
||||
type: "invalid",
|
||||
reason: "REQUEST_INVALIDATED",
|
||||
}
|
||||
// @ts-expect-error - We're deleting the data to invalidate the handle
|
||||
delete this.issuedHandles[idx].value.data
|
||||
|
||||
this.issuedHandles[idx].value.type = "invalid"
|
||||
|
||||
// @ts-expect-error - Setting the handle invalidation reason
|
||||
this.issuedHandles[idx].value.reason = "REQUEST_INVALIDATED"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,12 +35,11 @@ export class RESTTabService extends TabService<HoppRESTDocument> {
|
||||
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
|
||||
const resolvedTabData = this.getResolvedTabData(tab)
|
||||
|
||||
return {
|
||||
tabID: tab.id,
|
||||
doc: {
|
||||
...this.getPersistedDocument(resolvedTabData.document),
|
||||
...this.getPersistedDocument(tab.document),
|
||||
response: null,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -46,10 +46,7 @@ export abstract class TabService<Doc>
|
||||
})
|
||||
|
||||
public currentActiveTab = computed(() => {
|
||||
const tab = this.tabMap.get(this.currentTabID.value)!
|
||||
return this.getResolvedTabData(
|
||||
tab as HoppTab<HoppRESTDocument | HoppGQLDocument>
|
||||
)
|
||||
return this.tabMap.get(this.currentTabID.value)!
|
||||
}) // Guaranteed to not be undefined
|
||||
|
||||
protected watchCurrentTabID() {
|
||||
@@ -87,15 +84,7 @@ export abstract class TabService<Doc>
|
||||
}
|
||||
|
||||
public getActiveTab(): HoppTab<Doc> | null {
|
||||
const tab = this.tabMap.get(this.currentTabID.value)
|
||||
|
||||
if (!tab) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.getResolvedTabData(
|
||||
tab as HoppTab<HoppRESTDocument | HoppGQLDocument>
|
||||
)
|
||||
return this.tabMap.get(this.currentTabID.value) ?? null
|
||||
}
|
||||
|
||||
public setActiveTab(tabID: string): void {
|
||||
@@ -172,13 +161,7 @@ export abstract class TabService<Doc>
|
||||
public getActiveTabs(): Readonly<ComputedRef<HoppTab<Doc>[]>> {
|
||||
return shallowReadonly(
|
||||
computed(() =>
|
||||
this.tabOrdering.value.map((x) => {
|
||||
const tab = this.tabMap.get(x) as HoppTab<
|
||||
HoppRESTDocument | HoppGQLDocument
|
||||
>
|
||||
|
||||
return this.getResolvedTabData(tab)
|
||||
})
|
||||
this.tabOrdering.value.map((x) => this.tabMap.get(x) as HoppTab<Doc>)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -186,13 +169,11 @@ export abstract class TabService<Doc>
|
||||
public getTabRef(tabID: string) {
|
||||
return computed({
|
||||
get: () => {
|
||||
const result = this.tabMap.get(tabID) as HoppTab<
|
||||
HoppRESTDocument | HoppGQLDocument
|
||||
>
|
||||
const result = this.tabMap.get(tabID)
|
||||
|
||||
if (result === undefined) throw new Error(`Invalid tab id: ${tabID}`)
|
||||
|
||||
return this.getResolvedTabData(result)
|
||||
return result
|
||||
},
|
||||
set: (value) => {
|
||||
return this.tabMap.set(tabID, value)
|
||||
@@ -304,13 +285,10 @@ export abstract class TabService<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
|
||||
const resolvedTabData = this.getResolvedTabData(
|
||||
tab as HoppTab<HoppRESTDocument | HoppGQLDocument>
|
||||
)
|
||||
|
||||
return {
|
||||
tabID: tab.id,
|
||||
doc: this.getPersistedDocument(resolvedTabData.document),
|
||||
doc: this.getPersistedDocument(tab.document),
|
||||
}
|
||||
}),
|
||||
}))
|
||||
@@ -328,32 +306,4 @@ export abstract class TabService<Doc>
|
||||
if (!this.tabMap.has(id)) return id
|
||||
}
|
||||
}
|
||||
|
||||
protected getResolvedTabData(
|
||||
tab: HoppTab<HoppRESTDocument | HoppGQLDocument>
|
||||
): HoppTab<Doc> {
|
||||
if (
|
||||
tab.document.isDirty ||
|
||||
!tab.document.saveContext ||
|
||||
tab.document.saveContext.originLocation !== "workspace-user-collection"
|
||||
) {
|
||||
return tab as HoppTab<Doc>
|
||||
}
|
||||
|
||||
const requestHandle = tab.document.saveContext.requestHandle as
|
||||
| HandleRef<WorkspaceRequest>["value"]
|
||||
| undefined
|
||||
|
||||
if (!requestHandle) {
|
||||
return tab as HoppTab<Doc>
|
||||
}
|
||||
|
||||
return {
|
||||
...tab,
|
||||
document: {
|
||||
...tab.document,
|
||||
isDirty: requestHandle.type === "invalid",
|
||||
},
|
||||
} as HoppTab<Doc>
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user