diff --git a/docs/FEATURES.md b/docs/FEATURES.md index fd9069f7..2673ecb6 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -11,6 +11,7 @@ * **Modern Editor Specs:** Font ligatures, smooth scrolling, bracket pair colorization, and parameter hints enabled. * **Keyboard Shortcuts:** `Cmd/Ctrl + Enter` to execute, `Alt + Shift + F` to format. * **Command Palette:** Quick access to tables, connections, saved queries, and actions with `Cmd/Ctrl+K`. +* **Reopen Closed Tab:** The undo button beside the new-tab button restores the last closed query's name, text and language. Recovery is available until another tab is closed, the connection changes or the page reloads; reopening starts with fresh execution state. Available in both the standalone app and embedded workspaces. > See [`docs/editor/`](editor/) for the editor internals — completion provider, alias resolution, and performance design. diff --git a/src/components/Studio.tsx b/src/components/Studio.tsx index 30258b22..6babf24c 100644 --- a/src/components/Studio.tsx +++ b/src/components/Studio.tsx @@ -563,6 +563,8 @@ export default function Studio() { onSetTabs={tabMgr.setTabs} onCloseTab={tabMgr.closeTab} onAddTab={tabMgr.addTab} + onReopenClosedTab={tabMgr.reopenClosedTab} + canReopenClosedTab={tabMgr.canReopenClosedTab} />
diff --git a/src/components/studio/StudioTabBar.tsx b/src/components/studio/StudioTabBar.tsx index d036e691..c1708538 100644 --- a/src/components/studio/StudioTabBar.tsx +++ b/src/components/studio/StudioTabBar.tsx @@ -3,7 +3,7 @@ import React, { type Dispatch, type SetStateAction } from "react"; import type { QueryTab } from "@/lib/types"; import { cn } from "@/lib/utils"; -import { FileBraces, Hash, Plus, X } from "lucide-react"; +import { FileBraces, Hash, Plus, X, Undo2 } from "lucide-react"; interface StudioTabBarProps { tabs: QueryTab[]; @@ -16,6 +16,8 @@ interface StudioTabBarProps { onSetTabs: Dispatch>; onCloseTab: (id: string, e: React.MouseEvent) => void; onAddTab: () => void; + onReopenClosedTab?: () => void; + canReopenClosedTab?: boolean; } export function StudioTabBar({ @@ -29,6 +31,8 @@ export function StudioTabBar({ onSetTabs, onCloseTab, onAddTab, + onReopenClosedTab, + canReopenClosedTab = false, }: StudioTabBarProps) { // Roving tabindex (WAI-ARIA tabs pattern): arrows/Home/End move activation, // and focus follows the newly activated tab. @@ -156,6 +160,18 @@ export function StudioTabBar({ > + {onReopenClosedTab && ( + + )} ); } diff --git a/src/hooks/use-tab-manager.ts b/src/hooks/use-tab-manager.ts index 440fc16e..7ca14c9b 100644 --- a/src/hooks/use-tab-manager.ts +++ b/src/hooks/use-tab-manager.ts @@ -44,6 +44,7 @@ export function useTabManager({ activeConnection, metadata, schema, persistWorks const [editingTabId, setEditingTabId] = useState(null); const [editingTabName, setEditingTabName] = useState(""); const [isWorkspaceHydrated, setIsWorkspaceHydrated] = useState(false); + const [closedTab, setClosedTab] = useState<{ workspaceKey: string; tab: PersistedTabState } | null>(null); const workspaceKey = useMemo( () => `${WORKSPACE_STORAGE_PREFIX}:${activeConnection?.id ?? "default"}`, @@ -52,6 +53,7 @@ export function useTabManager({ activeConnection, metadata, schema, persistWorks const shouldPersistWorkspace = persistWorkspace ?? process.env.NODE_ENV !== "test"; const currentTab = tabs.find((t) => t.id === activeTabId) || tabs[0]; + const canReopenClosedTab = closedTab?.workspaceKey === workspaceKey; // LOAD EFFECT — restore tabs from localStorage on connection switch useEffect(() => { @@ -61,6 +63,9 @@ export function useTabManager({ activeConnection, metadata, schema, persistWorks // load/ready handshake, not state that could be computed instead of set. // eslint-disable-next-line react-hooks/set-state-in-effect setIsWorkspaceHydrated(false); + // A closed query belongs only to the connection where it was closed. + // eslint-disable-next-line react-hooks/set-state-in-effect + setClosedTab(null); if (!shouldPersistWorkspace) return; const storage = typeof globalThis !== "undefined" && "localStorage" in globalThis ? globalThis.localStorage : null; @@ -169,18 +174,25 @@ export function useTabManager({ activeConnection, metadata, schema, persistWorks const closeTab = useCallback( (id: string, e: React.MouseEvent) => { e.stopPropagation(); - setTabs((prev) => { - if (prev.length === 1) return prev; - const newTabs = prev.filter((t) => t.id !== id); - if (activeTabId === id && newTabs.length > 0) { - setActiveTabId(newTabs[newTabs.length - 1].id); - } - return newTabs; - }); + const tab = tabs.find((t) => t.id === id); + if (tabs.length <= 1 || !tab) return; + setClosedTab({ workspaceKey, tab: { id, name: tab.name, query: tab.query, type: tab.type } }); + const newTabs = tabs.filter((t) => t.id !== id); + setTabs((prev) => (prev.length > 1 ? prev.filter((t) => t.id !== id) : prev)); + if (activeTabId === id) setActiveTabId(newTabs[newTabs.length - 1].id); }, - [activeTabId], + [tabs, activeTabId, workspaceKey], ); + const reopenClosedTab = useCallback(() => { + if (!closedTab || closedTab.workspaceKey !== workspaceKey) return; + // A fresh ID prevents an in-flight request for the closed tab updating its replacement. + const tab: QueryTab = { ...closedTab.tab, id: newLocalId(), result: null, isExecuting: false }; + setTabs((prev) => [...prev, tab]); + setActiveTabId(tab.id); + setClosedTab(null); + }, [closedTab, workspaceKey]); + // handleTableClick takes executeQuery as callback param to avoid circular dependency const handleTableClick = useCallback( (tableName: string, executeQueryFn: (query: string, tabId: string) => void) => { @@ -251,6 +263,8 @@ export function useTabManager({ activeConnection, metadata, schema, persistWorks setEditingTabName, addTab, closeTab, + reopenClosedTab, + canReopenClosedTab, updateCurrentTab, updateTabById, handleTableClick, diff --git a/src/workspace/StudioWorkspace.tsx b/src/workspace/StudioWorkspace.tsx index e4622fca..13953c8c 100644 --- a/src/workspace/StudioWorkspace.tsx +++ b/src/workspace/StudioWorkspace.tsx @@ -349,6 +349,8 @@ export function StudioWorkspace({ onSetTabs={tabMgr.setTabs} onCloseTab={tabMgr.closeTab} onAddTab={tabMgr.addTab} + onReopenClosedTab={tabMgr.reopenClosedTab} + canReopenClosedTab={tabMgr.canReopenClosedTab} />
diff --git a/tests/components/studio/StudioTabBar.test.tsx b/tests/components/studio/StudioTabBar.test.tsx index bac6fbf3..63f14c97 100644 --- a/tests/components/studio/StudioTabBar.test.tsx +++ b/tests/components/studio/StudioTabBar.test.tsx @@ -117,6 +117,17 @@ describe("StudioTabBar", () => { // ── Close button ────────────────────────────────────────────────────── + test("offers reopening without adding a confirmation to close", () => { + const onReopenClosedTab = mock(() => {}); + const props = createDefaultProps({ onReopenClosedTab, canReopenClosedTab: false }); + const { getByRole, rerender } = render(); + const reopen = getByRole("button", { name: "Reopen last closed tab" }) as HTMLButtonElement; + expect(reopen.disabled).toBe(true); + rerender(); + fireEvent.click(reopen); + expect(onReopenClosedTab).toHaveBeenCalledTimes(1); + }); + test("close button fires onCloseTab when multiple tabs", () => { const onCloseTab = mock(() => {}); const props = createDefaultProps({ onCloseTab }); diff --git a/tests/hooks/use-tab-manager.test.ts b/tests/hooks/use-tab-manager.test.ts index e88e3162..773d8834 100644 --- a/tests/hooks/use-tab-manager.test.ts +++ b/tests/hooks/use-tab-manager.test.ts @@ -221,6 +221,98 @@ describe("useTabManager", () => { expect(result.current.currentTab.name).toBe("Renamed Tab"); }); + test("reopens the last closed query with its name and language, but without stale execution state", () => { + const { result } = renderHook(() => useTabManager({ activeConnection: null, metadata: null, schema: [] })); + expect(result.current.canReopenClosedTab).toBe(false); + act(() => result.current.reopenClosedTab()); + expect(result.current.tabs).toHaveLength(1); + act(() => result.current.addTab()); + act(() => + result.current.updateCurrentTab({ + name: "Unsaved aggregation", + query: '{ "find": "users" }', + type: "mongodb", + isExecuting: true, + result: { rows: [{ id: 1 }], fields: ["id"], rowCount: 1, executionTime: 1 }, + isLoadingMore: true, + currentOffset: 50, + }), + ); + const closed = result.current.currentTab; + const stopPropagation = mock(() => {}); + act(() => result.current.closeTab(closed.id, { stopPropagation } as unknown as React.MouseEvent)); + expect(stopPropagation).toHaveBeenCalledTimes(1); + expect(result.current.canReopenClosedTab).toBe(true); + act(() => result.current.reopenClosedTab()); + expect(result.current.currentTab).toEqual({ + id: expect.any(String), + name: closed.name, + query: closed.query, + type: "mongodb", + result: null, + isExecuting: false, + }); + expect(result.current.currentTab.id).not.toBe(closed.id); + expect(result.current.canReopenClosedTab).toBe(false); + act(() => result.current.updateTabById(closed.id, { isExecuting: true })); + expect(result.current.currentTab.isExecuting).toBe(false); + act(() => result.current.reopenClosedTab()); + expect(result.current.tabs).toHaveLength(2); + }); + + test("remembers only the last close and does not replace it when no tab was closed", () => { + const { result } = renderHook(() => useTabManager({ activeConnection: null, metadata: null, schema: [] })); + const event = { stopPropagation() {} } as React.MouseEvent; + act(() => result.current.closeTab("default", event)); + expect(result.current.canReopenClosedTab).toBe(false); + act(() => result.current.addTab()); + act(() => result.current.addTab()); + const [first, second, third] = result.current.tabs; + act(() => result.current.closeTab(first.id, event)); + expect(result.current.activeTabId).toBe(third.id); + act(() => result.current.closeTab(second.id, event)); + act(() => result.current.closeTab("missing", event)); + act(() => result.current.closeTab(third.id, event)); + act(() => result.current.reopenClosedTab()); + expect(result.current.currentTab.name).toBe(second.name); + expect(result.current.tabs).toHaveLength(2); + act(() => result.current.closeTab("missing", event)); + expect(result.current.canReopenClosedTab).toBe(false); + }); + + test("discards the closed query when switching connections, including when persistence is disabled", () => { + const { result, rerender } = renderHook( + ({ connection }) => + useTabManager({ + activeConnection: connection, + metadata: null, + schema: [], + persistWorkspace: false, + }), + { initialProps: { connection: makeConnection() } }, + ); + act(() => result.current.addTab()); + act(() => result.current.closeTab("default", { stopPropagation() {} } as React.MouseEvent)); + expect(result.current.canReopenClosedTab).toBe(true); + rerender({ connection: makeConnection({ id: "conn-2" }) }); + expect(result.current.canReopenClosedTab).toBe(false); + act(() => result.current.reopenClosedTab()); + expect(result.current.tabs).toHaveLength(1); + rerender({ connection: makeConnection() }); + expect(result.current.canReopenClosedTab).toBe(false); + }); + + test("closing a tab preserves a queued update to another tab", () => { + const { result } = renderHook(() => useTabManager({ activeConnection: null, metadata: null, schema: [] })); + act(() => result.current.addTab()); + const closedId = result.current.activeTabId; + act(() => { + result.current.updateTabById("default", { query: "SELECT 42;" }); + result.current.closeTab(closedId, { stopPropagation() {} } as React.MouseEvent); + }); + expect(result.current.currentTab.query).toBe("SELECT 42;"); + }); + test("updateTabById updates only the targeted tab query", () => { const { result } = renderHook(() => useTabManager({