feat(editor): add sql editor split panes with shared results

This commit is contained in:
Lan-zk authored and GitHub committed 2026-09-04 14:44:03 +08:00
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