feat(editor): add sql editor split panes with shared results
This commit is contained in:
1 parent
24a274bd89
commit
5a3a6e2bb0
76 files changed
+7997
-3343
No files matched your search
@@ -0,0 +1,111 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.setConfig({ testTimeout: 10_000 });
|
||||
|
||||
// The detach chain must never touch the persistence transport: adopting a
|
||||
// handoff is an in-memory store transition; the file only moves via
|
||||
// saveDetachedTabHandoff in App-level code, which these tests do not drive.
|
||||
vi.mock("@/lib/backend/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/backend/api")>();
|
||||
return {
|
||||
...actual,
|
||||
loadOpenTabsState: vi.fn(() => Promise.resolve(null)),
|
||||
saveOpenTabsState: vi.fn(() => Promise.resolve()),
|
||||
saveDetachedTabHandoff: vi.fn(() => Promise.resolve()),
|
||||
deleteDetachedTabHandoff: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
});
|
||||
|
||||
describe("queryStore detached-tab handoff", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal("window", {
|
||||
dispatchEvent: vi.fn(),
|
||||
setTimeout: vi.fn((_fn: () => void) => 0),
|
||||
clearTimeout: vi.fn(),
|
||||
});
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
async function makeHandoff() {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = store.createTab("pg-1", "app", "Query 1", "query");
|
||||
// A result-less query tab keeps prepareDetachedTab off the backend cache.
|
||||
const handoff = await store.prepareDetachedTab(tabId);
|
||||
return { store, tabId, handoff };
|
||||
}
|
||||
|
||||
it("adopting a handoff lands the tab in the main pane so the split workspace can render it", async () => {
|
||||
const { handoff } = await makeHandoff();
|
||||
|
||||
// Fresh store = the detached window's store before adoption.
|
||||
setActivePinia(createPinia());
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const freshStore = useQueryStore();
|
||||
expect(freshStore.tabs).toHaveLength(0);
|
||||
|
||||
await freshStore.adoptDetachedTab(handoff);
|
||||
|
||||
expect(freshStore.tabs.map((tab) => tab.id)).toEqual([handoff.tabId]);
|
||||
// Without group membership the workspace renders the empty-group
|
||||
// placeholder regardless of tabs/activeTabId — membership is the fix.
|
||||
expect(freshStore.groups[0]?.tabIds).toEqual([handoff.tabId]);
|
||||
expect(freshStore.groups[0]?.activeTabId).toBe(handoff.tabId);
|
||||
expect(freshStore.activeTabId).toBe(handoff.tabId);
|
||||
expect(freshStore.focusedGroupId).toBe(freshStore.groups[0]?.id);
|
||||
});
|
||||
|
||||
it("removeTabAfterDetachedReady clears the owning pane and prunes it when emptied", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const mainTabId = store.createTab("pg-1", "app", "Query 1", "query");
|
||||
const detachedTabId = store.createTab("pg-1", "app", "Query 2", "query");
|
||||
store.splitTabRight(detachedTabId);
|
||||
expect(store.groups).toHaveLength(2);
|
||||
store.activateTab(detachedTabId);
|
||||
|
||||
expect(store.removeTabAfterDetachedReady(detachedTabId)).toBe(true);
|
||||
|
||||
// No dangling group entry: the emptied pane is pruned, the main pane and
|
||||
// its tab survive, and the global active tab re-syncs to a live tab.
|
||||
expect(store.tabs.map((tab) => tab.id)).toEqual([mainTabId]);
|
||||
expect(store.groups).toHaveLength(1);
|
||||
expect(store.groups[0]?.tabIds).toEqual([mainTabId]);
|
||||
expect(store.activeTabId).toBe(mainTabId);
|
||||
});
|
||||
|
||||
it("returning a detached tab re-homes it into the workspace after its pane was pruned", async () => {
|
||||
const { store, tabId, handoff } = await makeHandoff();
|
||||
store.activateTab(tabId);
|
||||
store.removeTabAfterDetachedReady(tabId);
|
||||
expect(store.tabs).toHaveLength(0);
|
||||
|
||||
await store.adoptDetachedTab(handoff);
|
||||
|
||||
expect(store.tabs.map((tab) => tab.id)).toEqual([tabId]);
|
||||
expect(store.groups[0]?.tabIds).toEqual([tabId]);
|
||||
expect(store.activeTabId).toBe(tabId);
|
||||
});
|
||||
|
||||
it("re-adopting the same handoff does not duplicate the tab or its group entry", async () => {
|
||||
const { handoff } = await makeHandoff();
|
||||
|
||||
setActivePinia(createPinia());
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const freshStore = useQueryStore();
|
||||
await freshStore.adoptDetachedTab(handoff);
|
||||
await freshStore.adoptDetachedTab(handoff);
|
||||
|
||||
expect(freshStore.tabs).toHaveLength(1);
|
||||
expect(freshStore.groups[0]?.tabIds).toEqual([handoff.tabId]);
|
||||
expect(freshStore.activeTabId).toBe(handoff.tabId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.setConfig({ testTimeout: 10_000 });
|
||||
|
||||
// Mock the persistence transport for the whole file: real timers scheduled by
|
||||
// one test's debounced persist must not leak a previous store's tabs into a
|
||||
// later test's localStorage-backed restore payload.
|
||||
const restoreWorkspacePayload = vi.hoisted(() => ({
|
||||
tabs: Array.from({ length: 5 }, (_, index) => ({ id: `tab-${index}`, title: `Query ${index + 1}`, connectionId: "pg-1", database: "app", sql: "select 1" })),
|
||||
activeTabId: "tab-0",
|
||||
groups: Array.from({ length: 5 }, (_, index) => ({ id: `g${index}`, tabIds: [`tab-${index}`], activeTabId: `tab-${index}` })),
|
||||
focusedGroupId: "g0",
|
||||
orientation: "vertical",
|
||||
sizes: [20, 20, 20, 20, 20],
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/backend/api")>();
|
||||
return {
|
||||
...actual,
|
||||
loadOpenTabsState: vi.fn(() => Promise.resolve(restoreWorkspacePayload)),
|
||||
saveOpenTabsState: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
});
|
||||
|
||||
describe("queryStore editor groups", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal("window", {
|
||||
dispatchEvent: vi.fn(),
|
||||
setTimeout: vi.fn((_fn: () => void) => 0),
|
||||
clearTimeout: vi.fn(),
|
||||
});
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("splits down and toggles orientation", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const firstId = store.createTab("pg-1", "app", "Query 1", "query");
|
||||
store.createTab("pg-1", "app", "Query 2", "query");
|
||||
|
||||
expect(store.splitTabDown(firstId)).toBe(true);
|
||||
expect(store.groups).toHaveLength(2);
|
||||
|
||||
store.setOrientation("horizontal");
|
||||
expect(store.orientation).toBe("horizontal");
|
||||
store.setOrientation("vertical");
|
||||
expect(store.orientation).toBe("vertical");
|
||||
});
|
||||
|
||||
it("moves a tab across groups and removes the empty source group", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const firstId = store.createTab("pg-1", "app", "Query 1", "query");
|
||||
const secondId = store.createTab("pg-1", "app", "Query 2", "query");
|
||||
const thirdId = store.createTab("pg-1", "app", "Query 3", "query");
|
||||
|
||||
store.splitTabRight(firstId);
|
||||
const rightGroup = store.groups[1];
|
||||
|
||||
expect(store.moveTabToGroup(secondId, rightGroup.id)).toBe(true);
|
||||
expect(rightGroup.tabIds).toContain(secondId);
|
||||
expect(store.groups[0].tabIds).toEqual([thirdId]);
|
||||
expect(store.groups).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("unsplits a secondary group tab back to the main group", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const firstId = store.createTab("pg-1", "app", "Query 1", "query");
|
||||
const secondId = store.createTab("pg-1", "app", "Query 2", "query");
|
||||
|
||||
store.splitTabRight(firstId);
|
||||
const mainId = store.groups[0].id;
|
||||
|
||||
expect(store.unsplitTab(firstId)).toBe(true);
|
||||
expect(store.groups).toHaveLength(1);
|
||||
expect(store.groups[0].id).toBe(mainId);
|
||||
expect(store.groups[0].tabIds).toEqual([secondId, firstId]);
|
||||
expect(store.focusedGroupId).toBe(mainId);
|
||||
});
|
||||
|
||||
it("activates a tab atomically across group, focus, and global active tab", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const firstId = store.createTab("pg-1", "app", "Query 1", "query");
|
||||
const thirdId = store.createTab("pg-1", "app", "Query 3", "query");
|
||||
|
||||
store.splitTabRight(firstId);
|
||||
const leftGroup = store.groups[0];
|
||||
const rightGroup = store.groups[1];
|
||||
|
||||
expect(store.activateTab(thirdId)).toBe(true);
|
||||
expect(leftGroup.activeTabId).toBe(thirdId);
|
||||
expect(store.focusedGroupId).toBe(leftGroup.id);
|
||||
expect(store.activeTabId).toBe(thirdId);
|
||||
|
||||
expect(store.activateTab(firstId)).toBe(true);
|
||||
expect(rightGroup.activeTabId).toBe(firstId);
|
||||
expect(store.focusedGroupId).toBe(rightGroup.id);
|
||||
expect(store.activeTabId).toBe(firstId);
|
||||
|
||||
expect(store.activateTab("missing")).toBe(false);
|
||||
});
|
||||
|
||||
it("close all in group only closes the same pinned partition as the trigger tab", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const firstId = store.createTab("pg-1", "app", "Query 1", "query");
|
||||
const secondId = store.createTab("pg-1", "app", "Query 2", "query");
|
||||
const thirdId = store.createTab("pg-1", "app", "Query 3", "query");
|
||||
|
||||
store.togglePinnedTab(secondId);
|
||||
const mainId = store.groups[0].id;
|
||||
|
||||
store.closeAllTabsInGroup(mainId, firstId);
|
||||
|
||||
expect(store.tabs.some((tab) => tab.id === firstId)).toBe(false);
|
||||
expect(store.tabs.some((tab) => tab.id === thirdId)).toBe(false);
|
||||
expect(store.tabs.some((tab) => tab.id === secondId)).toBe(true);
|
||||
|
||||
store.closeAllTabsInGroup(mainId, secondId);
|
||||
expect(store.tabs.some((tab) => tab.id === secondId)).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes more than four groups at the restore boundary without orphaning tabs", async () => {
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
await useSettingsStore().initEditorSettings();
|
||||
const store = useQueryStore();
|
||||
await store.initOpenTabs();
|
||||
|
||||
expect(store.groups.length).toBeLessThanOrEqual(4);
|
||||
const assigned = store.groups.flatMap((group) => group.tabIds);
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
expect(assigned.filter((candidate) => candidate === `tab-${index}`)).toHaveLength(1);
|
||||
}
|
||||
expect(store.activeTabId).toBe(store.groups[0].activeTabId);
|
||||
});
|
||||
|
||||
it("derives the restored global active tab from the focused group, not the stale payload value", async () => {
|
||||
// The payload is internally inconsistent: activeTabId points into g2 while
|
||||
// focusedGroupId is g1. Restore must trust the focused group.
|
||||
restoreWorkspacePayload.activeTabId = "tab-1";
|
||||
restoreWorkspacePayload.groups = [
|
||||
{ id: "g1", tabIds: ["tab-0"], activeTabId: "tab-0" },
|
||||
{ id: "g2", tabIds: ["tab-1"], activeTabId: "tab-1" },
|
||||
];
|
||||
restoreWorkspacePayload.focusedGroupId = "g1";
|
||||
restoreWorkspacePayload.sizes = [50, 50];
|
||||
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
await useSettingsStore().initEditorSettings();
|
||||
const store = useQueryStore();
|
||||
await store.initOpenTabs();
|
||||
|
||||
expect(store.focusedGroupId).toBe("g1");
|
||||
expect(store.activeTabId).toBe("tab-0");
|
||||
expect(store.groups[1].activeTabId).toBe("tab-1");
|
||||
|
||||
restoreWorkspacePayload.activeTabId = "tab-0";
|
||||
restoreWorkspacePayload.groups = Array.from({ length: 5 }, (_, index) => ({ id: `g${index}`, tabIds: [`tab-${index}`], activeTabId: `tab-${index}` }));
|
||||
restoreWorkspacePayload.focusedGroupId = "g0";
|
||||
restoreWorkspacePayload.sizes = [20, 20, 20, 20, 20];
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.setConfig({ testTimeout: 10_000 });
|
||||
|
||||
describe("queryStore split right", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal("window", { dispatchEvent: vi.fn() });
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("moves a query tab into a new right group and focuses it", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const queryStore = useQueryStore();
|
||||
const firstId = queryStore.createTab("pg-1", "app", "Query 1", "query");
|
||||
const secondId = queryStore.createTab("pg-1", "app", "Query 2", "query");
|
||||
|
||||
const ok = queryStore.splitTabRight(firstId);
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(queryStore.groups).toHaveLength(2);
|
||||
expect(queryStore.groups[0].tabIds).toEqual([secondId]);
|
||||
expect(queryStore.groups[1].tabIds).toEqual([firstId]);
|
||||
expect(queryStore.focusedGroupId).toBe(queryStore.groups[1].id);
|
||||
expect(queryStore.activeTabId).toBe(firstId);
|
||||
});
|
||||
|
||||
it("allows up to four groups and rejects the fifth split", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const queryStore = useQueryStore();
|
||||
const ids = [
|
||||
queryStore.createTab("pg-1", "app", "Query 1", "query"),
|
||||
queryStore.createTab("pg-1", "app", "Query 2", "query"),
|
||||
queryStore.createTab("pg-1", "app", "Query 3", "query"),
|
||||
queryStore.createTab("pg-1", "app", "Query 4", "query"),
|
||||
queryStore.createTab("pg-1", "app", "Query 5", "query"),
|
||||
];
|
||||
|
||||
expect(queryStore.splitTabRight(ids[1])).toBe(true);
|
||||
expect(queryStore.groups).toHaveLength(2);
|
||||
expect(queryStore.splitTabRight(ids[2])).toBe(true);
|
||||
expect(queryStore.groups).toHaveLength(3);
|
||||
expect(queryStore.splitTabRight(ids[3])).toBe(true);
|
||||
expect(queryStore.groups).toHaveLength(4);
|
||||
expect(queryStore.splitTabRight(ids[4])).toBe(false);
|
||||
expect(queryStore.groups).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("splits non-query tabs into their own group like query tabs", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const queryStore = useQueryStore();
|
||||
// A companion query tab keeps the store past the single-tab guard.
|
||||
queryStore.createTab("pg-1", "app", "Query 1", "query");
|
||||
const dataId = queryStore.createTab("pg-1", "app", "users", "data", "public");
|
||||
|
||||
expect(queryStore.splitTabRight(dataId)).toBe(true);
|
||||
expect(queryStore.groups).toHaveLength(2);
|
||||
expect(queryStore.groups[1].tabIds).toEqual([dataId]);
|
||||
expect(queryStore.groups[1].activeTabId).toBe(dataId);
|
||||
expect(queryStore.activeTabId).toBe(dataId);
|
||||
});
|
||||
|
||||
it("rejects splitting the only open tab, which would be a no-op", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const queryStore = useQueryStore();
|
||||
const onlyId = queryStore.createTab("pg-1", "app", "Query 1", "query");
|
||||
const groupIdBefore = queryStore.groups[0].id;
|
||||
|
||||
const ok = queryStore.splitTabRight(onlyId);
|
||||
|
||||
// Splitting would move the tab into a new group and prune the emptied
|
||||
// source group, returning to the same single-group layout — rejected so
|
||||
// the menu item can render disabled instead of doing nothing.
|
||||
expect(ok).toBe(false);
|
||||
expect(queryStore.groups).toHaveLength(1);
|
||||
expect(queryStore.groups[0].id).toBe(groupIdBefore);
|
||||
expect(queryStore.groups[0].tabIds).toEqual([onlyId]);
|
||||
expect(queryStore.focusedGroupId).toBe(groupIdBefore);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user