import Component from "./component.js"; import SpacedUpdate from "../services/spaced_update.js"; import server from "../services/server.js"; import options from "../services/options.js"; import froca from "../services/froca.js"; import treeService from "../services/tree.js"; import NoteContext from "./note_context.js"; import appContext from "./app_context.js"; import Mutex from "../utils/mutex.js"; import linkService from "../services/link.js"; import type { EventData } from "./app_context.js"; import type FNote from "../entities/fnote.js"; interface TabState { contexts: NoteContext[]; position: number; } interface NoteContextState { ntxId: string; mainNtxId: string | null; notePath: string | null; hoistedNoteId: string; active: boolean; viewScope: Record; } export default class TabManager extends Component { public children: NoteContext[]; public mutex: Mutex; public activeNtxId: string | null; public recentlyClosedTabs: TabState[]; public tabsUpdate: SpacedUpdate; constructor() { super(); this.children = []; this.mutex = new Mutex(); this.activeNtxId = null; this.recentlyClosedTabs = []; this.tabsUpdate = new SpacedUpdate(async () => { if (!appContext.isMainWindow) { return; } const openNoteContexts = this.noteContexts .map((nc) => nc.getPojoState()) .filter((t) => !!t); await server.put("options", { openNoteContexts: JSON.stringify(openNoteContexts) }); }); appContext.addBeforeUnloadListener(this); } get noteContexts(): NoteContext[] { return this.children; } get mainNoteContexts(): NoteContext[] { return this.noteContexts.filter((nc) => !nc.mainNtxId); } async loadTabs() { try { const noteContextsToOpen = (appContext.isMainWindow && options.getJson("openNoteContexts")) || []; // preload all notes at once await froca.getNotes([...noteContextsToOpen.flatMap((tab: NoteContextState) => [treeService.getNoteIdFromUrl(tab.notePath), tab.hoistedNoteId])], true); const filteredNoteContexts = noteContextsToOpen.filter((openTab: NoteContextState) => { const noteId = treeService.getNoteIdFromUrl(openTab.notePath); if (noteId && !(noteId in froca.notes)) { // note doesn't exist so don't try to open tab for it return false; } if (!(openTab.hoistedNoteId in froca.notes)) { openTab.hoistedNoteId = "root"; } return true; }); // resolve before opened tabs can change this const parsedFromUrl = linkService.parseNavigationStateFromUrl(window.location.href); if (filteredNoteContexts.length === 0) { parsedFromUrl.ntxId = parsedFromUrl.ntxId || NoteContext.generateNtxId(); // generate already here, so that we later know which one to activate filteredNoteContexts.push({ notePath: parsedFromUrl.notePath || "root", ntxId: parsedFromUrl.ntxId, active: true, hoistedNoteId: parsedFromUrl.hoistedNoteId || "root", viewScope: parsedFromUrl.viewScope || {}, mainNtxId: null }); } else if (!filteredNoteContexts.find((tab: NoteContextState) => tab.active)) { filteredNoteContexts[0].active = true; } await this.tabsUpdate.allowUpdateWithoutChange(async () => { for (const tab of filteredNoteContexts) { await this.openContextWithNote(tab.notePath, { activate: tab.active, ntxId: tab.ntxId, mainNtxId: tab.mainNtxId, hoistedNoteId: tab.hoistedNoteId, viewScope: tab.viewScope }); } }); // if there's a notePath in the URL, make sure it's open and active // (useful, for e.g., opening clipped notes from clipper or opening link in an extra window) if (parsedFromUrl.notePath) { await appContext.tabManager.switchToNoteContext( parsedFromUrl.ntxId, parsedFromUrl.notePath, parsedFromUrl.viewScope, parsedFromUrl.hoistedNoteId ); } else if (parsedFromUrl.searchString) { await appContext.triggerCommand("searchNotes", { searchString: parsedFromUrl.searchString }); } } catch (e: unknown) { if (e instanceof Error) { logError(`Loading note contexts '${options.get("openNoteContexts")}' failed: ${e.message} ${e.stack}`); } else { logError(`Loading note contexts '${options.get("openNoteContexts")}' failed: ${String(e)}`); } // try to recover await this.openEmptyTab(); } } noteSwitchedEvent({ noteContext }: EventData<"noteSwitched">) { if (noteContext.isActive()) { this.setCurrentNavigationStateToHash(); } this.tabsUpdate.scheduleUpdate(); } setCurrentNavigationStateToHash() { const calculatedHash = this.calculateHash(); // update if it's the first history entry or there has been a change if (window.history.length === 0 || calculatedHash !== window.location?.hash) { // using pushState instead of directly modifying document.location because it does not trigger hashchange window.history.pushState(null, "", calculatedHash); } const activeNoteContext = this.getActiveContext(); this.updateDocumentTitle(activeNoteContext); this.triggerEvent("activeNoteChangedEvent", {}); // trigger this even in on popstate event } calculateHash(): string { const activeNoteContext = this.getActiveContext(); if (!activeNoteContext) { return ""; } return linkService.calculateHash({ notePath: activeNoteContext.notePath, ntxId: activeNoteContext.ntxId, hoistedNoteId: activeNoteContext.hoistedNoteId, viewScope: activeNoteContext.viewScope }); } getNoteContexts(): NoteContext[] { return this.noteContexts; } getMainNoteContexts(): NoteContext[] { return this.noteContexts.filter((nc) => nc.isMainContext()); } getNoteContextById(ntxId: string | null): NoteContext { const noteContext = this.noteContexts.find((nc) => nc.ntxId === ntxId); if (!noteContext) { throw new Error(`Cannot find noteContext id='${ntxId}'`); } return noteContext; } getActiveContext(): NoteContext | null { return this.activeNtxId ? this.getNoteContextById(this.activeNtxId) : null; } getActiveMainContext(): NoteContext | null { return this.activeNtxId ? this.getNoteContextById(this.activeNtxId).getMainContext() : null; } getActiveContextNotePath(): string | null { const activeContext = this.getActiveContext(); return activeContext?.notePath ?? null; } getActiveContextNote(): FNote | null { const activeContext = this.getActiveContext(); return activeContext ? activeContext.note : null; } getActiveContextNoteId(): string | null { const activeNote = this.getActiveContextNote(); return activeNote ? activeNote.noteId : null; } getActiveContextNoteType(): string | null { const activeNote = this.getActiveContextNote(); return activeNote ? activeNote.type : null; } getActiveContextNoteMime(): string | null { const activeNote = this.getActiveContextNote(); return activeNote ? activeNote.mime : null; } async switchToNoteContext( ntxId: string | null, notePath: string, viewScope: Record = {}, hoistedNoteId: string | null = null ) { const noteContext = this.noteContexts.find((nc) => nc.ntxId === ntxId) || await this.openEmptyTab(); await this.activateNoteContext(noteContext.ntxId); if (hoistedNoteId) { await noteContext.setHoistedNoteId(hoistedNoteId); } if (notePath) { await noteContext.setNote(notePath, { viewScope }); } } async openAndActivateEmptyTab() { const noteContext = await this.openEmptyTab(); await this.activateNoteContext(noteContext.ntxId); noteContext.setEmpty(); } async openEmptyTab( ntxId: string | null = null, hoistedNoteId: string = "root", mainNtxId: string | null = null ): Promise { const noteContext = new NoteContext(ntxId, hoistedNoteId, mainNtxId); const existingNoteContext = this.children.find((nc) => nc.ntxId === noteContext.ntxId); if (existingNoteContext) { await existingNoteContext.setHoistedNoteId(hoistedNoteId); return existingNoteContext; } this.child(noteContext); await this.triggerEvent("newNoteContextCreated", { noteContext }); return noteContext; } async openInNewTab(targetNoteId: string, hoistedNoteId: string | null = null) { const noteContext = await this.openEmptyTab( null, hoistedNoteId || this.getActiveContext()?.hoistedNoteId ); await noteContext.setNote(targetNoteId); } async openInSameTab(targetNoteId: string, hoistedNoteId: string | null = null) { const activeContext = this.getActiveContext(); if (!activeContext) return; await activeContext.setHoistedNoteId(hoistedNoteId || activeContext.hoistedNoteId); await activeContext.setNote(targetNoteId); } async openTabWithNoteWithHoisting( notePath: string, opts: { activate?: boolean | null; ntxId?: string | null; mainNtxId?: string | null; hoistedNoteId?: string | null; viewScope?: Record | null; } = {} ): Promise { const noteContext = this.getActiveContext(); let hoistedNoteId = "root"; if (noteContext) { const resolvedNotePath = await treeService.resolveNotePath(notePath, noteContext.hoistedNoteId); if (resolvedNotePath?.includes(noteContext.hoistedNoteId) || resolvedNotePath?.includes("_hidden")) { hoistedNoteId = noteContext.hoistedNoteId; } } opts.hoistedNoteId = hoistedNoteId; return this.openContextWithNote(notePath, opts); } async openContextWithNote( notePath: string | null, opts: { activate?: boolean | null; ntxId?: string | null; mainNtxId?: string | null; hoistedNoteId?: string | null; viewScope?: Record | null; } = {} ): Promise { const activate = !!opts.activate; const ntxId = opts.ntxId || null; const mainNtxId = opts.mainNtxId || null; const hoistedNoteId = opts.hoistedNoteId || "root"; const viewScope = opts.viewScope || { viewMode: "default" }; const noteContext = await this.openEmptyTab(ntxId, hoistedNoteId, mainNtxId); if (notePath) { await noteContext.setNote(notePath, { // if activate is false, then send normal noteSwitched event triggerSwitchEvent: !activate, viewScope: viewScope }); } if (activate && noteContext.notePath) { this.activateNoteContext(noteContext.ntxId, false); await this.triggerEvent("noteSwitchedAndActivatedEvent", { noteContext, notePath: noteContext.notePath // resolved note path }); } return noteContext; } async activateOrOpenNote(noteId: string) { for (const noteContext of this.getNoteContexts()) { if (noteContext.note && noteContext.note.noteId === noteId) { this.activateNoteContext(noteContext.ntxId); return; } } // if no tab with this note has been found we'll create new tab await this.openContextWithNote(noteId, { activate: true }); } async activateNoteContext(ntxId: string | null, triggerEvent: boolean = true) { if (!ntxId) { logError("activateNoteContext: ntxId is null"); return; } if (ntxId === this.activeNtxId) { return; } this.activeNtxId = ntxId; if (triggerEvent) { await this.triggerEvent("activeContextChanged", { noteContext: this.getNoteContextById(ntxId) }); } this.tabsUpdate.scheduleUpdate(); this.setCurrentNavigationStateToHash(); } async removeNoteContext(ntxId: string | null) { // removing note context is an async process which can take some time, if users presses CTRL-W quickly, two // close events could interleave which would then lead to attempting to activate already removed context. return await this.mutex.runExclusively(async () => { let noteContextToRemove; try { noteContextToRemove = this.getNoteContextById(ntxId); } catch { // note context not found return; } if (noteContextToRemove.isMainContext()) { const mainNoteContexts = this.getNoteContexts().filter((nc) => nc.isMainContext()); if (mainNoteContexts.length === 1) { if (noteContextToRemove.isEmpty()) { // this is already the empty note context, no point in closing it and replacing with another // empty tab return; } await this.openEmptyTab(); } } // close dangling autocompletes after closing the tab const $autocompleteEl = $(".aa-input"); if ("autocomplete" in $autocompleteEl) { $autocompleteEl.autocomplete("close"); } const noteContextsToRemove = noteContextToRemove.getSubContexts(); const ntxIdsToRemove = noteContextsToRemove.map((nc) => nc.ntxId); await this.triggerEvent("beforeNoteContextRemove", { ntxIds: ntxIdsToRemove.filter((id) => id !== null) }); if (!noteContextToRemove.isMainContext()) { const siblings = noteContextToRemove.getMainContext().getSubContexts(); const idx = siblings.findIndex((nc) => nc.ntxId === noteContextToRemove.ntxId); const contextToActivateIdx = idx === siblings.length - 1 ? idx - 1 : idx + 1; const contextToActivate = siblings[contextToActivateIdx]; await this.activateNoteContext(contextToActivate.ntxId); } else if (this.mainNoteContexts.length <= 1) { await this.openAndActivateEmptyTab(); } else if (ntxIdsToRemove.includes(this.activeNtxId)) { const idx = this.mainNoteContexts.findIndex((nc) => nc.ntxId === noteContextToRemove.ntxId); if (idx === this.mainNoteContexts.length - 1) { await this.activatePreviousTabCommand(); } else { await this.activateNextTabCommand(); } } this.removeNoteContexts(noteContextsToRemove); }); } removeNoteContexts(noteContextsToRemove: NoteContext[]) { const ntxIdsToRemove = noteContextsToRemove.map((nc) => nc.ntxId); const position = this.noteContexts.findIndex((nc) => ntxIdsToRemove.includes(nc.ntxId)); this.children = this.children.filter((nc) => !ntxIdsToRemove.includes(nc.ntxId)); this.addToRecentlyClosedTabs(noteContextsToRemove, position); this.triggerEvent("noteContextRemoved", { ntxIds: ntxIdsToRemove.filter((id) => id !== null) }); this.tabsUpdate.scheduleUpdate(); } addToRecentlyClosedTabs(noteContexts: NoteContext[], position: number) { if (noteContexts.length === 1 && noteContexts[0].isEmpty()) { return; } this.recentlyClosedTabs.push({ contexts: noteContexts, position: position }); } tabReorderEvent({ ntxIdsInOrder }: { ntxIdsInOrder: string[] }) { const order: Record = {}; let i = 0; for (const ntxId of ntxIdsInOrder) { for (const noteContext of this.getNoteContextById(ntxId).getSubContexts()) { if (noteContext.ntxId) { order[noteContext.ntxId] = i++; } } } this.children.sort((a, b) => { if (!a.ntxId || !b.ntxId) return 0; return (order[a.ntxId] ?? 0) < (order[b.ntxId] ?? 0) ? -1 : 1; }); this.tabsUpdate.scheduleUpdate(); } noteContextReorderEvent({ ntxIdsInOrder, oldMainNtxId, newMainNtxId }: { ntxIdsInOrder: string[]; oldMainNtxId?: string; newMainNtxId?: string; }) { const order = Object.fromEntries(ntxIdsInOrder.map((v, i) => [v, i])); this.children.sort((a, b) => { if (!a.ntxId || !b.ntxId) return 0; return (order[a.ntxId] ?? 0) < (order[b.ntxId] ?? 0) ? -1 : 1; }); if (oldMainNtxId && newMainNtxId) { this.children.forEach((c) => { if (c.ntxId === newMainNtxId) { // new main context has null mainNtxId c.mainNtxId = null; } else if (c.ntxId === oldMainNtxId || c.mainNtxId === oldMainNtxId) { // old main context or subcontexts all have the new mainNtxId c.mainNtxId = newMainNtxId; } }); } this.tabsUpdate.scheduleUpdate(); } async activateNextTabCommand() { const activeMainNtxId = this.getActiveMainContext()?.ntxId; if (!activeMainNtxId) return; const oldIdx = this.mainNoteContexts.findIndex((nc) => nc.ntxId === activeMainNtxId); const newActiveNtxId = this.mainNoteContexts[oldIdx === this.mainNoteContexts.length - 1 ? 0 : oldIdx + 1].ntxId; await this.activateNoteContext(newActiveNtxId); } async activatePreviousTabCommand() { const activeMainNtxId = this.getActiveMainContext()?.ntxId; if (!activeMainNtxId) return; const oldIdx = this.mainNoteContexts.findIndex((nc) => nc.ntxId === activeMainNtxId); const newActiveNtxId = this.mainNoteContexts[oldIdx === 0 ? this.mainNoteContexts.length - 1 : oldIdx - 1].ntxId; await this.activateNoteContext(newActiveNtxId); } async closeActiveTabCommand() { if (this.activeNtxId) { await this.removeNoteContext(this.activeNtxId); } } beforeUnloadEvent(): boolean { this.tabsUpdate.updateNowIfNecessary(); return true; // don't block closing the tab, this metadata is not that important } openNewTabCommand() { this.openAndActivateEmptyTab(); } async closeAllTabsCommand() { for (const ntxIdToRemove of this.mainNoteContexts.map((nc) => nc.ntxId)) { if (ntxIdToRemove) { await this.removeNoteContext(ntxIdToRemove); } } } async closeOtherTabsCommand({ ntxId }: { ntxId: string }) { for (const ntxIdToRemove of this.mainNoteContexts.map((nc) => nc.ntxId)) { if (ntxIdToRemove && ntxIdToRemove !== ntxId) { await this.removeNoteContext(ntxIdToRemove); } } } async closeRightTabsCommand({ ntxId }: { ntxId: string }) { const ntxIds = this.mainNoteContexts.map((nc) => nc.ntxId); const index = ntxIds.indexOf(ntxId); if (index !== -1) { const idsToRemove = ntxIds.slice(index + 1); for (const ntxIdToRemove of idsToRemove) { if (ntxIdToRemove) { await this.removeNoteContext(ntxIdToRemove); } } } } async closeTabCommand({ ntxId }: { ntxId: string }) { await this.removeNoteContext(ntxId); } async moveTabToNewWindowCommand({ ntxId }: { ntxId: string }) { const { notePath, hoistedNoteId } = this.getNoteContextById(ntxId); const removed = await this.removeNoteContext(ntxId); if (removed) { this.triggerCommand("openInWindow", { notePath, hoistedNoteId }); } } async copyTabToNewWindowCommand({ ntxId }: { ntxId: string }) { const { notePath, hoistedNoteId } = this.getNoteContextById(ntxId); this.triggerCommand("openInWindow", { notePath, hoistedNoteId }); } async reopenLastTabCommand() { const closeLastEmptyTab: NoteContext | undefined = await this.mutex.runExclusively(async () => { let closeLastEmptyTab if (this.recentlyClosedTabs.length === 0) { return closeLastEmptyTab; } if (this.noteContexts.length === 1 && this.noteContexts[0].isEmpty()) { // new empty tab is created after closing the last tab, this reverses the empty tab creation closeLastEmptyTab = this.noteContexts[0]; } const lastClosedTab = this.recentlyClosedTabs.pop(); if (!lastClosedTab) return closeLastEmptyTab; const noteContexts = lastClosedTab.contexts; for (const noteContext of noteContexts) { this.child(noteContext); await this.triggerEvent("newNoteContextCreated", { noteContext }); } // restore last position of contexts stored in tab manager const ntxsInOrder = [ ...this.noteContexts.slice(0, lastClosedTab.position), ...this.noteContexts.slice(-noteContexts.length), ...this.noteContexts.slice(lastClosedTab.position, -noteContexts.length) ]; this.noteContextReorderEvent({ ntxIdsInOrder: ntxsInOrder.map((nc) => nc.ntxId).filter((id) => id !== null) }); let mainNtx = noteContexts.find((nc) => nc.isMainContext()); if (mainNtx) { // reopened a tab, need to reorder new tab widget in tab row await this.triggerEvent("contextsReopenedEvent", { mainNtxId: mainNtx.ntxId, tabPosition: ntxsInOrder.filter((nc) => nc.isMainContext()).findIndex((nc) => nc.ntxId === mainNtx.ntxId) }); } else { // reopened a single split, need to reorder the pane widget in split note container await this.triggerEvent("contextsReopenedEvent", { mainNtxId: ntxsInOrder[lastClosedTab.position].ntxId, // this is safe since lastClosedTab.position can never be 0 in this case tabPosition: lastClosedTab.position - 1 }); } const noteContextToActivate = noteContexts.length === 1 ? noteContexts[0] : noteContexts.find((nc) => nc.isMainContext()); if (!noteContextToActivate) return closeLastEmptyTab; await this.activateNoteContext(noteContextToActivate.ntxId); await this.triggerEvent("noteSwitched", { noteContext: noteContextToActivate, notePath: noteContextToActivate.notePath }); return closeLastEmptyTab; }); if (closeLastEmptyTab) { await this.removeNoteContext(closeLastEmptyTab.ntxId); } } hoistedNoteChangedEvent() { this.tabsUpdate.scheduleUpdate(); } async updateDocumentTitle(activeNoteContext: NoteContext | null) { if (!activeNoteContext) return; const titleFragments = [ // it helps to navigate in history if note title is included in the title await activeNoteContext.getNavigationTitle(), "TriliumNext Notes" ].filter(Boolean); document.title = titleFragments.join(" - "); } async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { const activeContext = this.getActiveContext(); if (activeContext && loadResults.isNoteReloaded(activeContext.noteId)) { await this.updateDocumentTitle(activeContext); } } async frocaReloadedEvent() { const activeContext = this.getActiveContext(); if (activeContext) { await this.updateDocumentTitle(activeContext); } } }