api-client/packages/hoppscotch-common/src/services/tab/tab.ts
Shreyas 824dce79d0
feat(desktop): url focus and mru tab shortcuts (#5683)
Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com>
2025-12-16 17:25:56 +05:30

407 lines
11 KiB
TypeScript

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>>()) as Map<
string,
HoppTab<Doc>
> // TODO: The implicit cast is necessary as the reactive unwraps the inner types, creating weird type errors, this needs to be refactored and removed
protected tabOrdering = ref<string[]>(["test"])
protected recentlyClosedTabs: Array<{ tab: HoppTab<Doc>; index: number }> = []
protected readonly MAX_CLOSED_TABS_HISTORY = 10
// MRU (Most Recently Used) tracking
// mruOrder[0] is the most recently used tab, mruOrder[n-1] is the least recently used
protected mruOrder: string[] = ["test"]
// Navigation index for cycling through MRU list while modifier key is held
// -1 means not currently navigating, 0+ means current position in mruOrder
protected mruNavigationIndex: number = -1
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 async init() {
const persistedState = await this.loadPersistedState()
if (persistedState) {
this.loadTabsFromPersistedState(persistedState)
}
}
protected abstract loadPersistedState(): Promise<PersistableTabState<Doc> | null>
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)
}
// Note: We don't add to mruOrder here if switchToIt is false
// The tab will be added to mruOrder when it's first activated via setActiveTab
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
this.updateMRUOrder(tabID)
}
private updateMRUOrder(tabID: string): void {
const index = this.mruOrder.indexOf(tabID)
if (index > -1) {
this.mruOrder.splice(index, 1)
}
this.mruOrder.unshift(tabID)
// Reset navigation index when a tab is explicitly activated
// This ensures the next MRU navigation starts fresh
this.mruNavigationIndex = -1
}
public loadTabsFromPersistedState(data: PersistableTabState<Doc>): void {
if (data) {
this.tabMap.clear()
this.tabOrdering.value = []
this.mruOrder = []
this.mruNavigationIndex = -1
for (const doc of data.orderedDocs) {
this.tabMap.set(doc.tabID, {
id: doc.tabID,
document: doc.doc,
})
this.tabOrdering.value.push(doc.tabID)
this.mruOrder.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
}
const tabIndex = this.tabOrdering.value.indexOf(tabID)
const tab = this.tabMap.get(tabID)!
this.addToRecentlyClosedTabs(tab, tabIndex)
const mruIndex = this.mruOrder.indexOf(tabID)
if (mruIndex > -1) {
this.mruOrder.splice(mruIndex, 1)
}
// Reset navigation index when tabs change
this.mruNavigationIndex = -1
this.tabOrdering.value.splice(tabIndex, 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.mruOrder = [tabID]
this.mruNavigationIndex = -1
this.tabMap.forEach((_, id) => {
if (id !== tabID) this.tabMap.delete(id)
})
this.currentTabID.value = tabID
}
public goToNextTab(): void {
const currentIndex = this.tabOrdering.value.indexOf(this.currentTabID.value)
const nextIndex = (currentIndex + 1) % this.tabOrdering.value.length
const nextTabID = this.tabOrdering.value[nextIndex]
this.setActiveTab(nextTabID)
}
public goToPreviousTab(): void {
const currentIndex = this.tabOrdering.value.indexOf(this.currentTabID.value)
const prevIndex =
currentIndex === 0 ? this.tabOrdering.value.length - 1 : currentIndex - 1
const prevTabID = this.tabOrdering.value[prevIndex]
this.setActiveTab(prevTabID)
}
// NOTE: Currently inert, plumbing is done, some platform issues around shortcuts, WIP for future.
public goToTabByIndex(index: number): void {
if (index >= 1 && index <= this.tabOrdering.value.length) {
const tabID = this.tabOrdering.value[index - 1]
this.setActiveTab(tabID)
}
}
public goToFirstTab(): void {
const firstTabID = this.tabOrdering.value[0]
this.setActiveTab(firstTabID)
}
public goToLastTab(): void {
const lastTabID = this.tabOrdering.value[this.tabOrdering.value.length - 1]
this.setActiveTab(lastTabID)
}
public reopenClosedTab(): boolean {
if (this.recentlyClosedTabs.length === 0) {
return false
}
const { tab, index } = this.recentlyClosedTabs.pop()!
this.tabMap.set(tab.id, tab)
const insertIndex = Math.min(index, this.tabOrdering.value.length)
this.tabOrdering.value.splice(insertIndex, 0, tab.id)
this.setActiveTab(tab.id)
return true
}
/**
* Navigates forward through the MRU list (to older tabs).
* Each call moves one step forward in the MRU history.
*
* Behavior:
* - First call: moves from current tab (index 0) to second most recent (index 1)
* - Subsequent calls: continue moving forward through history
* - Wraps around to the beginning when reaching the end
*/
public goToMRUTab(): void {
// Clean up any stale entries
this.mruOrder = this.mruOrder.filter((tabID) => this.tabMap.has(tabID))
if (this.mruOrder.length <= 1) return
// If not currently navigating, start from position 0 (current tab)
if (this.mruNavigationIndex === -1) {
this.mruNavigationIndex = 0
}
// Move forward in MRU list (toward older tabs)
this.mruNavigationIndex =
(this.mruNavigationIndex + 1) % this.mruOrder.length
const targetTabID = this.mruOrder[this.mruNavigationIndex]
if (targetTabID) {
// Use currentTabID directly to avoid resetting navigation state
this.currentTabID.value = targetTabID
}
}
/**
* Navigates backward through the MRU list (to more recent tabs).
* Each call moves one step backward in the MRU history.
*
* Behavior:
* - Moves backward from current position in MRU navigation
* - Wraps around to the end when reaching the beginning
*/
public goToPreviousMRUTab(): void {
// Clean up any stale entries
this.mruOrder = this.mruOrder.filter((tabID) => this.tabMap.has(tabID))
if (this.mruOrder.length <= 1) return
// If not currently navigating, start from position 0 (current tab)
if (this.mruNavigationIndex === -1) {
this.mruNavigationIndex = 0
}
// Move backward in MRU list (toward more recent tabs)
this.mruNavigationIndex =
this.mruNavigationIndex === 0
? this.mruOrder.length - 1
: this.mruNavigationIndex - 1
const targetTabID = this.mruOrder[this.mruNavigationIndex]
if (targetTabID) {
// Use currentTabID directly to avoid resetting navigation state
this.currentTabID.value = targetTabID
}
}
/**
* Commits the current MRU navigation selection.
* Should be called when the modifier key is released to finalize the tab switch.
* This moves the selected tab to the front of the MRU order.
*/
public commitMRUNavigation(): void {
if (this.mruNavigationIndex > 0) {
const selectedTabID = this.mruOrder[this.mruNavigationIndex]
if (selectedTabID) {
// Move the selected tab to the front of MRU order
this.mruOrder.splice(this.mruNavigationIndex, 1)
this.mruOrder.unshift(selectedTabID)
}
}
this.mruNavigationIndex = -1
}
/**
* Resets MRU navigation state without committing.
* Useful if navigation is cancelled.
*/
public resetMRUNavigation(): void {
this.mruNavigationIndex = -1
}
private addToRecentlyClosedTabs(tab: HoppTab<Doc>, index: number): void {
this.recentlyClosedTabs.push({ tab, index })
if (this.recentlyClosedTabs.length > this.MAX_CLOSED_TABS_HISTORY) {
this.recentlyClosedTabs.shift()
}
}
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
}
}
}