Browse Source

refactor: keep tab dirty status logic at the page level

jamesgeorge007 10 months ago
parent
commit
bf13cd80d5

+ 0 - 43
packages/hoppscotch-common/src/components/new-collections/rest/index.vue

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

+ 153 - 62
packages/hoppscotch-common/src/pages/index.vue

@@ -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"

+ 17 - 10
packages/hoppscotch-common/src/services/new-workspace/providers/personal.workspace.ts

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

+ 1 - 2
packages/hoppscotch-common/src/services/tab/rest.ts

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

+ 6 - 56
packages/hoppscotch-common/src/services/tab/tab.ts

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