diff --git a/apps/desktop/src/components/main/body/index.tsx b/apps/desktop/src/components/main/body/index.tsx index ec240a7554..881c3c583c 100644 --- a/apps/desktop/src/components/main/body/index.tsx +++ b/apps/desktop/src/components/main/body/index.tsx @@ -614,12 +614,13 @@ function useScrollActiveTabIntoView(tabs: Tab[]) { } function useTabsShortcuts() { - const { tabs, currentTab, close, select } = useTabs( + const { tabs, currentTab, close, select, restoreLastClosedTab } = useTabs( useShallow((state) => ({ tabs: state.tabs, currentTab: state.currentTab, close: state.close, select: state.select, + restoreLastClosedTab: state.restoreLastClosedTab, })), ); const newNote = useNewNote({ behavior: "new" }); @@ -688,6 +689,17 @@ function useTabsShortcuts() { [tabs, select], ); + useHotkeys( + "mod+shift+t", + () => restoreLastClosedTab(), + { + preventDefault: true, + enableOnFormTags: true, + enableOnContentEditable: true, + }, + [restoreLastClosedTab], + ); + return {}; } diff --git a/apps/desktop/src/store/zustand/tabs/index.ts b/apps/desktop/src/store/zustand/tabs/index.ts index 69900b5888..1c7d10a225 100644 --- a/apps/desktop/src/store/zustand/tabs/index.ts +++ b/apps/desktop/src/store/zustand/tabs/index.ts @@ -14,25 +14,35 @@ import { navigationMiddleware, type NavigationState, } from "./navigation"; +import { + createRestoreSlice, + type RestoreActions, + restoreMiddleware, + type RestoreState, +} from "./restore"; import { createStateUpdaterSlice, type StateBasicActions } from "./state"; export type { Tab, TabInput } from "./schema"; export { isSameTab, rowIdfromTab, uniqueIdfromTab } from "./schema"; -type State = BasicState & NavigationState & LifecycleState; +type State = BasicState & NavigationState & LifecycleState & RestoreState; type Actions = BasicActions & StateBasicActions & NavigationActions & - LifecycleActions; + LifecycleActions & + RestoreActions; type Store = State & Actions; export const useTabs = create()( - lifecycleMiddleware( - navigationMiddleware((set, get) => ({ - ...wrapSliceWithLogging("basic", createBasicSlice(set, get)), - ...wrapSliceWithLogging("state", createStateUpdaterSlice(set, get)), - ...wrapSliceWithLogging("navigation", createNavigationSlice(set, get)), - ...wrapSliceWithLogging("lifecycle", createLifecycleSlice(set, get)), - })), + restoreMiddleware( + lifecycleMiddleware( + navigationMiddleware((set, get) => ({ + ...wrapSliceWithLogging("basic", createBasicSlice(set, get)), + ...wrapSliceWithLogging("state", createStateUpdaterSlice(set, get)), + ...wrapSliceWithLogging("navigation", createNavigationSlice(set, get)), + ...wrapSliceWithLogging("lifecycle", createLifecycleSlice(set, get)), + ...wrapSliceWithLogging("restore", createRestoreSlice(set, get)), + })), + ), ), ); diff --git a/apps/desktop/src/store/zustand/tabs/restore.ts b/apps/desktop/src/store/zustand/tabs/restore.ts new file mode 100644 index 0000000000..e4346ef1e7 --- /dev/null +++ b/apps/desktop/src/store/zustand/tabs/restore.ts @@ -0,0 +1,91 @@ +import type { StoreApi } from "zustand"; + +import type { Tab, TabInput } from "./schema"; + +const MAX_CLOSED_TABS = 10; + +export type RestoreState = { + closedTabs: Tab[]; +}; + +export type RestoreActions = { + restoreLastClosedTab: () => void; +}; + +const tabToTabInput = (tab: Tab): TabInput => { + const { active, slotId, pinned, ...rest } = tab as Tab & { + active: boolean; + slotId: string; + pinned: boolean; + }; + return rest as TabInput; +}; + +export const createRestoreSlice = < + T extends RestoreState & { tabs: Tab[]; openNew: (tab: TabInput) => void }, +>( + set: StoreApi["setState"], + get: StoreApi["getState"], +): RestoreState & RestoreActions => ({ + closedTabs: [], + restoreLastClosedTab: () => { + const { closedTabs, openNew } = get(); + if (closedTabs.length === 0) return; + + const lastClosed = closedTabs[closedTabs.length - 1]; + const remainingClosedTabs = closedTabs.slice(0, -1); + + openNew(tabToTabInput(lastClosed)); + set({ closedTabs: remainingClosedTabs } as Partial); + }, +}); + +type RestoreMiddleware = < + T extends { + tabs: Tab[]; + closedTabs: Tab[]; + }, +>( + f: ( + set: StoreApi["setState"], + get: StoreApi["getState"], + api: StoreApi, + ) => T, +) => ( + set: StoreApi["setState"], + get: StoreApi["getState"], + api: StoreApi, +) => T; + +const restoreMiddlewareImpl: RestoreMiddleware = + (config) => (set, get, api) => { + return config( + (args) => { + const prevState = get(); + const prevTabs = prevState.tabs; + + set(args); + + const nextState = get(); + const nextTabs = nextState.tabs; + + const closedTabs = prevTabs.filter( + (prevTab) => + !nextTabs.some((nextTab) => nextTab.slotId === prevTab.slotId), + ); + + if (closedTabs.length > 0) { + const updatedClosedTabs = [ + ...nextState.closedTabs, + ...closedTabs, + ].slice(-MAX_CLOSED_TABS); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + set({ closedTabs: updatedClosedTabs } as any); + } + }, + get, + api, + ); + }; + +export const restoreMiddleware = restoreMiddlewareImpl;