407 lines
11 KiB
TypeScript
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
|
|
}
|
|
}
|
|
}
|