Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions src/components/Studio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,8 @@ export default function Studio() {
onSetTabs={tabMgr.setTabs}
onCloseTab={tabMgr.closeTab}
onAddTab={tabMgr.addTab}
onReopenClosedTab={tabMgr.reopenClosedTab}
canReopenClosedTab={tabMgr.canReopenClosedTab}
/>

<main className="flex-1 overflow-hidden relative">
Expand Down
18 changes: 17 additions & 1 deletion src/components/studio/StudioTabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -16,6 +16,8 @@ interface StudioTabBarProps {
onSetTabs: Dispatch<SetStateAction<QueryTab[]>>;
onCloseTab: (id: string, e: React.MouseEvent) => void;
onAddTab: () => void;
onReopenClosedTab?: () => void;
canReopenClosedTab?: boolean;
}

export function StudioTabBar({
Expand All @@ -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.
Expand Down Expand Up @@ -156,6 +160,18 @@ export function StudioTabBar({
>
<Plus strokeWidth={1.5} className="w-3.5 h-3.5" />
</button>
{onReopenClosedTab && (
<button
type="button"
aria-label="Reopen last closed tab"
title="Reopen last closed tab"
disabled={!canReopenClosedTab}
className="text-fg-muted cursor-pointer hover:text-fg-bright mx-1 disabled:opacity-30 disabled:cursor-default"
onClick={onReopenClosedTab}
>
<Undo2 strokeWidth={1.5} className="w-3.5 h-3.5" />
</button>
)}
</div>
);
}
32 changes: 23 additions & 9 deletions src/hooks/use-tab-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export function useTabManager({ activeConnection, metadata, schema, persistWorks
const [editingTabId, setEditingTabId] = useState<string | null>(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"}`,
Expand All @@ -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(() => {
Expand All @@ -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;
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -251,6 +263,8 @@ export function useTabManager({ activeConnection, metadata, schema, persistWorks
setEditingTabName,
addTab,
closeTab,
reopenClosedTab,
canReopenClosedTab,
updateCurrentTab,
updateTabById,
handleTableClick,
Expand Down
2 changes: 2 additions & 0 deletions src/workspace/StudioWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,8 @@ export function StudioWorkspace({
onSetTabs={tabMgr.setTabs}
onCloseTab={tabMgr.closeTab}
onAddTab={tabMgr.addTab}
onReopenClosedTab={tabMgr.reopenClosedTab}
canReopenClosedTab={tabMgr.canReopenClosedTab}
/>

<main className="flex-1 overflow-hidden relative">
Expand Down
11 changes: 11 additions & 0 deletions tests/components/studio/StudioTabBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<StudioTabBar {...props} />);
const reopen = getByRole("button", { name: "Reopen last closed tab" }) as HTMLButtonElement;
expect(reopen.disabled).toBe(true);
rerender(<StudioTabBar {...props} canReopenClosedTab />);
fireEvent.click(reopen);
expect(onReopenClosedTab).toHaveBeenCalledTimes(1);
});

test("close button fires onCloseTab when multiple tabs", () => {
const onCloseTab = mock(() => {});
const props = createDefaultProps({ onCloseTab });
Expand Down
92 changes: 92 additions & 0 deletions tests/hooks/use-tab-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading