From 67c143cd2f00939a300590bdaf8ce7bd7542adde Mon Sep 17 00:00:00 2001 From: ytkimirti Date: Mon, 6 Jul 2026 08:39:33 +0300 Subject: [PATCH] feat: add reopen closed tab action to tab context menu --- CLAUDE.md | 2 +- src/components/databrowser/components/tab.tsx | 27 +- src/store.tsx | 681 ++++++++++-------- 3 files changed, 397 insertions(+), 313 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7a825aa..33aacfb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **Zustand** with persistence middleware manages tabs, key search, type filters, selections, and search history - **React Query** handles server state (key lists, values, metadata) -- **Persistence:** Optional `RedisBrowserStorage` interface for localStorage/custom storage. Schema is at version 7 with automated migrations from earlier versions. +- **Persistence:** Optional `RedisBrowserStorage` interface for localStorage/custom storage. Schema is versioned (see `version` in `src/store.tsx`) with automated migrations from earlier versions. ### Key Source Locations diff --git a/src/components/databrowser/components/tab.tsx b/src/components/databrowser/components/tab.tsx index 9ec5504..4dcb740 100644 --- a/src/components/databrowser/components/tab.tsx +++ b/src/components/databrowser/components/tab.tsx @@ -1,10 +1,11 @@ import type { TabId } from "@/store" -import { useDatabrowserStore } from "@/store" +import { useDatabrowserRootRef, useDatabrowserStore } from "@/store" import { useTab } from "@/tab-provider" import { IconArrowsMinimize, IconCopyPlus, IconPin, + IconRestore, IconSearch, IconSquareX, IconX, @@ -34,12 +35,27 @@ export const Tab = ({ id, isList }: { id: TabId; isList?: boolean }) => { duplicateTab, closeOtherTabs, closeAllButPinned, + closedTabs, + reopenClosedTab, } = useDatabrowserStore() + const rootRef = useDatabrowserRootRef() const hasPinnedTabs = tabs.some(([, data]) => data.pinned) const { ref, isOverflow } = useOverflow() + const handleReopenClosedTab = () => { + const reopenedId = reopenClosedTab() + if (!reopenedId) return + + setTimeout(() => { + const tab = rootRef?.current?.querySelector(`#tab-${reopenedId}`) + if (!tab) return + + tab.scrollIntoView({ behavior: "smooth" }) + }, 20) + } + const label = isValuesSearchSelected ? valuesSearch.index : search.key || selectedKey const iconNode = isValuesSearchSelected ? (
@@ -125,6 +141,15 @@ export const Tab = ({ id, isList }: { id: TabId; isList?: boolean }) => { Close All But Pinned + + + + Reopen Closed Tab + ) diff --git a/src/store.tsx b/src/store.tsx index 2d050e6..21b6a44 100644 --- a/src/store.tsx +++ b/src/store.tsx @@ -45,7 +45,7 @@ export const DatabrowserProvider = ({ setItem: (_name, value) => storage.set(JSON.stringify(value)), removeItem: () => {}, }, - version: 8, + version: 9, migrate: (originalState, version) => { const state = originalState as DatabrowserStore @@ -93,6 +93,11 @@ export const DatabrowserProvider = ({ state.aiDataSharingConsent = state.aiDataSharingConsent ?? false } + if (version <= 8) { + // Add recently closed tabs stack + state.closedTabs = state.closedTabs ?? [] + } + return state }, }) @@ -159,6 +164,10 @@ type DatabrowserStore = { selectedTab: TabId | undefined tabs: [TabId, TabData][] + // Recently closed tabs, grouped by close action so that one reopen + // restores everything a "Close Other Tabs" etc. closed at once + closedTabs: TabData[][] + addTab: () => TabId removeTab: (id: TabId) => void forceRemoveTab: (id: TabId) => void @@ -170,6 +179,7 @@ type DatabrowserStore = { duplicateTab: (id: TabId) => TabId | undefined closeOtherTabs: (id: TabId) => void closeAllButPinned: () => void + reopenClosedTab: () => TabId | undefined // Tab actions getSelectedKeys: (tabId: TabId) => string[] @@ -196,321 +206,370 @@ type DatabrowserStore = { export type DatabrowserStoreObject = UseBoundStore> -const storeCreator: StateCreator = (set, get) => ({ - selectedTab: undefined, - tabs: [], - - addTab: () => { - const id = crypto.randomUUID() as TabId - - const newTabData: TabData = { - id, - selectedKeys: [], - search: { key: "", type: undefined }, - valuesSearch: { index: "", queries: {}, queryBuilderMode: "ui" }, - isValuesSearchSelected: false, - pinned: false, - } - - set((old) => ({ - tabs: [...old.tabs, [id, newTabData]], - selectedTab: id, - })) - - return id - }, - - reorderTabs: (oldIndex, newIndex) => { - set((old) => { - // Don't allow reordering pinned tabs - const [, oldTabData] = old.tabs[oldIndex] - const [, newTabData] = old.tabs[newIndex] - if (oldTabData.pinned || newTabData.pinned) return old - - const newTabs = [...old.tabs] - const [movedTab] = newTabs.splice(oldIndex, 1) - newTabs.splice(newIndex, 0, movedTab) - return { ...old, tabs: newTabs } - }) - }, - - removeTab: (id) => { - set((old) => { - const tabIndex = old.tabs.findIndex(([tabId]) => tabId === id) - if (tabIndex === -1) return old - - // Don't remove pinned tabs via X button - const [, tabData] = old.tabs[tabIndex] - if (tabData.pinned) return old - - const newTabs = [...old.tabs] - newTabs.splice(tabIndex, 1) - - // If we're removing the selected tab, select the tab to the left - let selectedTab = old.selectedTab - if (selectedTab === id) { - const [newId] = newTabs[tabIndex - 1] ?? newTabs[tabIndex] - - selectedTab = newTabs.length > 0 ? newId : undefined +const MAX_CLOSED_TAB_GROUPS = 10 + +const storeCreator: StateCreator = (set, get) => { + const pushClosedTabs = (tabs: TabData[]) => + tabs.length > 0 ? [...get().closedTabs, tabs].slice(-MAX_CLOSED_TAB_GROUPS) : get().closedTabs + + return { + selectedTab: undefined, + tabs: [], + closedTabs: [], + + addTab: () => { + const id = crypto.randomUUID() as TabId + + const newTabData: TabData = { + id, + selectedKeys: [], + search: { key: "", type: undefined }, + valuesSearch: { index: "", queries: {}, queryBuilderMode: "ui" }, + isValuesSearchSelected: false, + pinned: false, } - return { tabs: newTabs, selectedTab } - }) - }, + set((old) => ({ + tabs: [...old.tabs, [id, newTabData]], + selectedTab: id, + })) + + return id + }, + + reorderTabs: (oldIndex, newIndex) => { + set((old) => { + // Don't allow reordering pinned tabs + const [, oldTabData] = old.tabs[oldIndex] + const [, newTabData] = old.tabs[newIndex] + if (oldTabData.pinned || newTabData.pinned) return old + + const newTabs = [...old.tabs] + const [movedTab] = newTabs.splice(oldIndex, 1) + newTabs.splice(newIndex, 0, movedTab) + return { ...old, tabs: newTabs } + }) + }, - forceRemoveTab: (id) => { - set((old) => { - const tabIndex = old.tabs.findIndex(([tabId]) => tabId === id) - if (tabIndex === -1) return old + removeTab: (id) => { + set((old) => { + const tabIndex = old.tabs.findIndex(([tabId]) => tabId === id) + if (tabIndex === -1) return old - const newTabs = [...old.tabs] - newTabs.splice(tabIndex, 1) + // Don't remove pinned tabs via X button + const [, tabData] = old.tabs[tabIndex] + if (tabData.pinned) return old - // If we're removing the selected tab, select the tab to the left - let selectedTab = old.selectedTab - if (selectedTab === id) { - const [newId] = newTabs[tabIndex - 1] ?? newTabs[tabIndex] + const newTabs = [...old.tabs] + newTabs.splice(tabIndex, 1) - selectedTab = newTabs.length > 0 ? newId : undefined - } + // If we're removing the selected tab, select the tab to the left + let selectedTab = old.selectedTab + if (selectedTab === id) { + const [newId] = newTabs[tabIndex - 1] ?? newTabs[tabIndex] ?? [] - return { tabs: newTabs, selectedTab } - }) - }, - - togglePinTab: (id) => { - set((old) => { - const tabIndex = old.tabs.findIndex(([tabId]) => tabId === id) - if (tabIndex === -1) return old - - const newTabs = [...old.tabs] - const [tabId, tabData] = newTabs[tabIndex] - newTabs[tabIndex] = [tabId, { ...tabData, pinned: !tabData.pinned }] - - return { ...old, tabs: newTabs } - }) - }, - - duplicateTab: (id) => { - let newId: TabId | undefined - set((old) => { - const tabIndex = old.tabs.findIndex(([tabId]) => tabId === id) - if (tabIndex === -1) return old - - const newTabs = [...old.tabs] - const [, tabData] = newTabs[tabIndex] - newId = crypto.randomUUID() as TabId - const duplicated: [TabId, TabData] = [newId, { ...tabData, id: newId }] - - // Insert right after the original tab - newTabs.splice(tabIndex + 1, 0, duplicated) - - return { ...old, tabs: newTabs, selectedTab: newId } - }) - return newId - }, - - closeOtherTabs: (id) => { - set((old) => { - const exists = old.tabs.some(([tabId]) => tabId === id) - if (!exists) return old - - const newTabs: [TabId, TabData][] = old.tabs.filter(([tabId]) => tabId === id) - return { ...old, tabs: newTabs, selectedTab: id } - }) - }, - - closeAllButPinned: () => { - set((old) => { - const newTabs = old.tabs.filter(([, data]) => data.pinned) - const newSelected = newTabs.length > 0 ? newTabs[0][0] : undefined - return { ...old, tabs: newTabs, selectedTab: newSelected } - }) - }, - - selectTab: (id) => { - set({ selectedTab: id }) - }, - - getSelectedKeys: (tabId) => { - return get().tabs.find(([id]) => id === tabId)?.[1]?.selectedKeys ?? [] - }, - - setSelectedKey: (tabId, key) => { - get().setSelectedKeys(tabId, key ? [key] : []) - }, - - setSelectedKeys: (tabId, keys) => { - set((old) => { - const tabIndex = old.tabs.findIndex(([id]) => id === tabId) - if (tabIndex === -1) return old - - const newTabs = [...old.tabs] - const [, tabData] = newTabs[tabIndex] - newTabs[tabIndex] = [tabId, { ...tabData, selectedKeys: keys, selectedListItem: undefined }] - - return { ...old, tabs: newTabs } - }) - }, - - setSelectedListItem: (tabId, item) => { - set((old) => { - const tabIndex = old.tabs.findIndex(([id]) => id === tabId) - if (tabIndex === -1) return old - - const newTabs = [...old.tabs] - const [, tabData] = newTabs[tabIndex] - newTabs[tabIndex] = [tabId, { ...tabData, selectedListItem: item }] - - return { ...old, tabs: newTabs } - }) - }, - - setSearch: (tabId, search) => { - set((old) => { - const tabIndex = old.tabs.findIndex(([id]) => id === tabId) - if (tabIndex === -1) return old - - const newTabs = [...old.tabs] - const [, tabData] = newTabs[tabIndex] - newTabs[tabIndex] = [tabId, { ...tabData, search }] - - return { ...old, tabs: newTabs } - }) - }, - - setSearchKey: (tabId, key) => { - set((old) => { - const tabIndex = old.tabs.findIndex(([id]) => id === tabId) - if (tabIndex === -1) return old - - const newTabs = [...old.tabs] - const [, tabData] = newTabs[tabIndex] - newTabs[tabIndex] = [ - tabId, - { - ...tabData, - search: { ...tabData.search, key }, - }, - ] - - return { ...old, tabs: newTabs } - }) - }, - - setSearchType: (tabId, type) => { - set((old) => { - const tabIndex = old.tabs.findIndex(([id]) => id === tabId) - if (tabIndex === -1) return old - - const newTabs = [...old.tabs] - const [, tabData] = newTabs[tabIndex] - newTabs[tabIndex] = [ - tabId, - { - ...tabData, - search: { ...tabData.search, type }, - }, - ] - - return { ...old, tabs: newTabs } - }) - }, - - setValuesSearch: (tabId, valuesSearch) => { - set((old) => { - const tabIndex = old.tabs.findIndex(([id]) => id === tabId) - if (tabIndex === -1) return old - - const newTabs = [...old.tabs] - const [, tabData] = newTabs[tabIndex] - newTabs[tabIndex] = [tabId, { ...tabData, valuesSearch }] - - return { ...old, tabs: newTabs } - }) - }, - - setValuesSearchIndex: (tabId, index) => { - set((old) => { - const tabIndex = old.tabs.findIndex(([id]) => id === tabId) - if (tabIndex === -1) return old - - const newTabs = [...old.tabs] - const [, tabData] = newTabs[tabIndex] - newTabs[tabIndex] = [ - tabId, - { - ...tabData, - valuesSearch: { ...tabData.valuesSearch, index }, - }, - ] - - return { ...old, tabs: newTabs } - }) - }, - - setValuesSearchQuery: (tabId, query) => { - set((old) => { - const tabIndex = old.tabs.findIndex(([id]) => id === tabId) - if (tabIndex === -1) return old - - const newTabs = [...old.tabs] - const [, tabData] = newTabs[tabIndex] - const currentIndex = tabData.valuesSearch.index - newTabs[tabIndex] = [ - tabId, - { - ...tabData, - valuesSearch: { - ...tabData.valuesSearch, - queries: { ...tabData.valuesSearch.queries, [currentIndex]: query }, + selectedTab = newTabs.length > 0 ? newId : undefined + } + + return { + tabs: newTabs, + selectedTab, + closedTabs: pushClosedTabs([tabData]), + } + }) + }, + + forceRemoveTab: (id) => { + set((old) => { + const tabIndex = old.tabs.findIndex(([tabId]) => tabId === id) + if (tabIndex === -1) return old + + const [, tabData] = old.tabs[tabIndex] + + const newTabs = [...old.tabs] + newTabs.splice(tabIndex, 1) + + // If we're removing the selected tab, select the tab to the left + let selectedTab = old.selectedTab + if (selectedTab === id) { + const [newId] = newTabs[tabIndex - 1] ?? newTabs[tabIndex] ?? [] + + selectedTab = newTabs.length > 0 ? newId : undefined + } + + return { + tabs: newTabs, + selectedTab, + closedTabs: pushClosedTabs([tabData]), + } + }) + }, + + togglePinTab: (id) => { + set((old) => { + const tabIndex = old.tabs.findIndex(([tabId]) => tabId === id) + if (tabIndex === -1) return old + + const newTabs = [...old.tabs] + const [tabId, tabData] = newTabs[tabIndex] + newTabs[tabIndex] = [tabId, { ...tabData, pinned: !tabData.pinned }] + + return { ...old, tabs: newTabs } + }) + }, + + duplicateTab: (id) => { + let newId: TabId | undefined + set((old) => { + const tabIndex = old.tabs.findIndex(([tabId]) => tabId === id) + if (tabIndex === -1) return old + + const newTabs = [...old.tabs] + const [, tabData] = newTabs[tabIndex] + newId = crypto.randomUUID() as TabId + const duplicated: [TabId, TabData] = [newId, { ...tabData, id: newId }] + + // Insert right after the original tab + newTabs.splice(tabIndex + 1, 0, duplicated) + + return { ...old, tabs: newTabs, selectedTab: newId } + }) + return newId + }, + + closeOtherTabs: (id) => { + set((old) => { + const exists = old.tabs.some(([tabId]) => tabId === id) + if (!exists) return old + + const closed = old.tabs.filter(([tabId]) => tabId !== id).map(([, data]) => data) + + const newTabs: [TabId, TabData][] = old.tabs.filter(([tabId]) => tabId === id) + return { + ...old, + tabs: newTabs, + selectedTab: id, + closedTabs: pushClosedTabs(closed), + } + }) + }, + + closeAllButPinned: () => { + set((old) => { + const closed = old.tabs.filter(([, data]) => !data.pinned).map(([, data]) => data) + + const newTabs = old.tabs.filter(([, data]) => data.pinned) + const newSelected = newTabs.length > 0 ? newTabs[0][0] : undefined + return { + ...old, + tabs: newTabs, + selectedTab: newSelected, + closedTabs: pushClosedTabs(closed), + } + }) + }, + + reopenClosedTab: () => { + let reopenedId: TabId | undefined + set((old) => { + const group = old.closedTabs.at(-1) + if (!group) return old + + reopenedId = group.at(-1)?.id + return { + ...old, + tabs: [...old.tabs, ...group.map((tab) => [tab.id, tab] as [TabId, TabData])], + selectedTab: reopenedId, + closedTabs: old.closedTabs.slice(0, -1), + } + }) + return reopenedId + }, + + selectTab: (id) => { + set({ selectedTab: id }) + }, + + getSelectedKeys: (tabId) => { + return get().tabs.find(([id]) => id === tabId)?.[1]?.selectedKeys ?? [] + }, + + setSelectedKey: (tabId, key) => { + get().setSelectedKeys(tabId, key ? [key] : []) + }, + + setSelectedKeys: (tabId, keys) => { + set((old) => { + const tabIndex = old.tabs.findIndex(([id]) => id === tabId) + if (tabIndex === -1) return old + + const newTabs = [...old.tabs] + const [, tabData] = newTabs[tabIndex] + newTabs[tabIndex] = [tabId, { ...tabData, selectedKeys: keys, selectedListItem: undefined }] + + return { ...old, tabs: newTabs } + }) + }, + + setSelectedListItem: (tabId, item) => { + set((old) => { + const tabIndex = old.tabs.findIndex(([id]) => id === tabId) + if (tabIndex === -1) return old + + const newTabs = [...old.tabs] + const [, tabData] = newTabs[tabIndex] + newTabs[tabIndex] = [tabId, { ...tabData, selectedListItem: item }] + + return { ...old, tabs: newTabs } + }) + }, + + setSearch: (tabId, search) => { + set((old) => { + const tabIndex = old.tabs.findIndex(([id]) => id === tabId) + if (tabIndex === -1) return old + + const newTabs = [...old.tabs] + const [, tabData] = newTabs[tabIndex] + newTabs[tabIndex] = [tabId, { ...tabData, search }] + + return { ...old, tabs: newTabs } + }) + }, + + setSearchKey: (tabId, key) => { + set((old) => { + const tabIndex = old.tabs.findIndex(([id]) => id === tabId) + if (tabIndex === -1) return old + + const newTabs = [...old.tabs] + const [, tabData] = newTabs[tabIndex] + newTabs[tabIndex] = [ + tabId, + { + ...tabData, + search: { ...tabData.search, key }, }, - }, - ] - - return { ...old, tabs: newTabs } - }) - }, - - setIsValuesSearchSelected: (tabId, isSelected) => { - set((old) => { - const tabIndex = old.tabs.findIndex(([id]) => id === tabId) - if (tabIndex === -1) return old - - const newTabs = [...old.tabs] - const [, tabData] = newTabs[tabIndex] - newTabs[tabIndex] = [tabId, { ...tabData, isValuesSearchSelected: isSelected }] - - return { ...old, tabs: newTabs } - }) - }, - - setQueryBuilderMode: (tabId, mode) => { - set((old) => { - const tabIndex = old.tabs.findIndex(([id]) => id === tabId) - if (tabIndex === -1) return old - - const newTabs = [...old.tabs] - const [, tabData] = newTabs[tabIndex] - newTabs[tabIndex] = [ - tabId, - { - ...tabData, - valuesSearch: { ...tabData.valuesSearch, queryBuilderMode: mode }, - }, - ] - - return { ...old, tabs: newTabs } - }) - }, - - searchHistory: [], - addSearchHistory: (key) => { - set((old) => ({ ...old, searchHistory: [key, ...old.searchHistory] })) - }, - - aiDataSharingConsent: false, - setAiDataSharingConsent: (consent) => { - set({ aiDataSharingConsent: consent }) - }, -}) + ] + + return { ...old, tabs: newTabs } + }) + }, + + setSearchType: (tabId, type) => { + set((old) => { + const tabIndex = old.tabs.findIndex(([id]) => id === tabId) + if (tabIndex === -1) return old + + const newTabs = [...old.tabs] + const [, tabData] = newTabs[tabIndex] + newTabs[tabIndex] = [ + tabId, + { + ...tabData, + search: { ...tabData.search, type }, + }, + ] + + return { ...old, tabs: newTabs } + }) + }, + + setValuesSearch: (tabId, valuesSearch) => { + set((old) => { + const tabIndex = old.tabs.findIndex(([id]) => id === tabId) + if (tabIndex === -1) return old + + const newTabs = [...old.tabs] + const [, tabData] = newTabs[tabIndex] + newTabs[tabIndex] = [tabId, { ...tabData, valuesSearch }] + + return { ...old, tabs: newTabs } + }) + }, + + setValuesSearchIndex: (tabId, index) => { + set((old) => { + const tabIndex = old.tabs.findIndex(([id]) => id === tabId) + if (tabIndex === -1) return old + + const newTabs = [...old.tabs] + const [, tabData] = newTabs[tabIndex] + newTabs[tabIndex] = [ + tabId, + { + ...tabData, + valuesSearch: { ...tabData.valuesSearch, index }, + }, + ] + + return { ...old, tabs: newTabs } + }) + }, + + setValuesSearchQuery: (tabId, query) => { + set((old) => { + const tabIndex = old.tabs.findIndex(([id]) => id === tabId) + if (tabIndex === -1) return old + + const newTabs = [...old.tabs] + const [, tabData] = newTabs[tabIndex] + const currentIndex = tabData.valuesSearch.index + newTabs[tabIndex] = [ + tabId, + { + ...tabData, + valuesSearch: { + ...tabData.valuesSearch, + queries: { ...tabData.valuesSearch.queries, [currentIndex]: query }, + }, + }, + ] + + return { ...old, tabs: newTabs } + }) + }, + + setIsValuesSearchSelected: (tabId, isSelected) => { + set((old) => { + const tabIndex = old.tabs.findIndex(([id]) => id === tabId) + if (tabIndex === -1) return old + + const newTabs = [...old.tabs] + const [, tabData] = newTabs[tabIndex] + newTabs[tabIndex] = [tabId, { ...tabData, isValuesSearchSelected: isSelected }] + + return { ...old, tabs: newTabs } + }) + }, + + setQueryBuilderMode: (tabId, mode) => { + set((old) => { + const tabIndex = old.tabs.findIndex(([id]) => id === tabId) + if (tabIndex === -1) return old + + const newTabs = [...old.tabs] + const [, tabData] = newTabs[tabIndex] + newTabs[tabIndex] = [ + tabId, + { + ...tabData, + valuesSearch: { ...tabData.valuesSearch, queryBuilderMode: mode }, + }, + ] + + return { ...old, tabs: newTabs } + }) + }, + + searchHistory: [], + addSearchHistory: (key) => { + set((old) => ({ ...old, searchHistory: [key, ...old.searchHistory] })) + }, + + aiDataSharingConsent: false, + setAiDataSharingConsent: (consent) => { + set({ aiDataSharingConsent: consent }) + }, + } +}