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

@@ -155,7 +155,7 @@ describe("useDataGridActions", () => {
const tab = tableDataTab();
const actions = useDataGridActions(computed(() => tab));
await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(
expect.objectContaining({
@@ -191,7 +191,9 @@ describe("useDataGridActions", () => {
const actions = useDataGridActions(computed(() => tab));
const { whereInput, orderBy } = restoredDataTabReloadFilters(tab);
await actions.onReloadData(undefined, undefined, whereInput, orderBy);
// ContentArea's empty-placeholder refresh emits the owning tabId first
// (tabId-first contract), so the filters land in the whereInput/orderBy slots.
await actions.onReloadData(tab.id, undefined, undefined, whereInput, orderBy);
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ whereInput: "status = 'active'", orderBy: '"name" DESC' }));
expect(tab.whereInput).toBe("status = 'active'");
@@ -205,7 +207,7 @@ describe("useDataGridActions", () => {
mocks.tabs.push(tab);
const actions = useDataGridActions(computed(() => tab));
await actions.onReloadData(tab.sql, "", undefined, undefined, undefined, undefined, "refresh");
await actions.onReloadData(tab.id, tab.sql, "", undefined, undefined, undefined, undefined, "refresh");
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ whereInput: undefined, orderBy: undefined }));
expect(tab.whereInput).toBe("");
@@ -219,7 +221,7 @@ describe("useDataGridActions", () => {
mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT * FROM public.users LIMIT 25 OFFSET 50");
const actions = useDataGridActions(computed(() => tab));
await actions.onReloadData(tab.sql, "", "", "", 25, 50, "refresh");
await actions.onReloadData(tab.id, tab.sql, "", "", "", 25, 50, "refresh");
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ limit: 25, offset: 50 }));
expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT * FROM public.users LIMIT 25 OFFSET 50", expect.objectContaining({ pagination: { limit: 25, offset: 50 } }));
@@ -238,7 +240,7 @@ describe("useDataGridActions", () => {
mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT * FROM public.users LIMIT 100 OFFSET 100");
const actions = useDataGridActions(computed(() => tab));
await actions.onPaginate(100, 100);
await actions.onPaginate(tab.id, 100, 100);
expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT * FROM public.users LIMIT 100 OFFSET 100", expect.objectContaining({ appendResult: { maxRows: 10_000 } }));
});
@@ -264,7 +266,7 @@ describe("useDataGridActions", () => {
mocks.tabs.push(tab);
const actions = useDataGridActions(computed(() => tab));
await actions.onReloadData(tab.sql, "", "", '"old_name" ASC', undefined, undefined, "refresh");
await actions.onReloadData(tab.id, tab.sql, "", "", '"old_name" ASC', undefined, undefined, "refresh");
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy: undefined }));
expect(tab.resultSortColumn).toBeUndefined();
@@ -289,7 +291,7 @@ describe("useDataGridActions", () => {
mocks.tabs.push(tab);
const actions = useDataGridActions(computed(() => tab));
await actions.onReloadData(tab.sql, "", "", '"old_name" ASC', undefined, undefined, "refresh");
await actions.onReloadData(tab.id, tab.sql, "", "", '"old_name" ASC', undefined, undefined, "refresh");
expect(mocks.clearInvalidDataTabSort).toHaveReturnedWith(false);
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy: undefined }));
@@ -317,7 +319,7 @@ describe("useDataGridActions", () => {
mocks.tabs.push(tab);
const actions = useDataGridActions(computed(() => tab));
await actions.onReloadData(tab.sql, "", "", "LOWER(new_name) ASC", undefined, undefined, "refresh");
await actions.onReloadData(tab.id, tab.sql, "", "", "LOWER(new_name) ASC", undefined, undefined, "refresh");
expect(mocks.clearInvalidDataTabSort).toHaveReturnedWith(true);
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy: "LOWER(new_name) ASC" }));
@@ -341,7 +343,7 @@ describe("useDataGridActions", () => {
} as QueryTab;
const actions = useDataGridActions(computed(() => tab));
await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
expect(mocks.buildTableSelectSql).not.toHaveBeenCalled();
expect(mocks.executeTabSql).toHaveBeenCalledWith(
@@ -366,7 +368,7 @@ describe("useDataGridActions", () => {
mocks.tabs.push(tab);
const actions = useDataGridActions(computed(() => tab));
await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
// 合成的 ["Error"] 结果列不得进入 SQL 投影:真实列缺失时省略 columns(SELECT *)
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ columns: undefined }));
@@ -387,7 +389,7 @@ describe("useDataGridActions", () => {
// 第一轮:stale-tab 早退(tabs 里找不到匹配项时 refreshDataTabTableMeta 直接返回)
mocks.tabs.length = 0;
await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
await vi.waitFor(() => expect(mocks.getColumns).toHaveBeenCalledTimes(1));
expect(mocks.setTableMeta).not.toHaveBeenCalled();
expect(tab.tableMetaPending).toBe(true);
@@ -395,7 +397,7 @@ describe("useDataGridActions", () => {
// 第二轮:真实 columns 仍为空,新的消费者加入同一在途请求;共享缓存应
// 去重后端调用,但本轮仍要在目标恢复后落地结果
mocks.tabs.push(tab);
await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
await vi.waitFor(() => {
expect(mocks.getColumns).toHaveBeenCalledTimes(1);
expect(mocks.setTableMeta).toHaveBeenCalledWith("tab-1", expect.objectContaining({ primaryKeys: ["id"] }));
@@ -422,7 +424,7 @@ describe("useDataGridActions", () => {
mocks.tabs.push(tab);
const actions = useDataGridActions(computed(() => tab));
await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
// Dameng 元数据必须排在数据查询之后(串行约束,同 useSidebarDataOpenRuntime);
// 真实元数据落地后结束行标识等待
@@ -445,7 +447,7 @@ describe("useDataGridActions", () => {
const actions = useDataGridActions(computed(() => tab));
// 查询 reject 仍会重新抛出,但元数据刷新必须已启动,标签页可恢复
await expect(actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh")).rejects.toThrow("query failed");
await expect(actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh")).rejects.toThrow("query failed");
await vi.waitFor(() => {
expect(mocks.getColumns).toHaveBeenCalled();
expect(tab.tableMetaPending).toBe(false);
@@ -473,7 +475,7 @@ describe("useDataGridActions", () => {
mocks.listIndexes.mockResolvedValueOnce([]);
const actions = useDataGridActions(computed(() => tab));
await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
expect(mocks.getColumns).toHaveBeenCalledTimes(1);
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ columns: ["id", "age"] }));
@@ -501,7 +503,7 @@ describe("useDataGridActions", () => {
mocks.getColumns.mockResolvedValueOnce([{ name: "new_name", data_type: "text", is_nullable: true, column_default: null, is_primary_key: false, extra: null }]);
const actions = useDataGridActions(computed(() => tab));
await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
expect(mocks.ensureConnected).toHaveBeenCalled();
expect(mocks.getColumns).toHaveBeenCalledTimes(1);
@@ -514,7 +516,7 @@ describe("useDataGridActions", () => {
mocks.tabs.push(tab);
const actions = useDataGridActions(computed(() => tab));
await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
expect(mocks.getColumns).not.toHaveBeenCalled();
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ columns: ["id"] }));
@@ -535,7 +537,7 @@ describe("useDataGridActions", () => {
}),
);
const actions = useDataGridActions(computed(() => tab));
const reload = actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
const reload = actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
await vi.waitFor(() => expect(mocks.getColumns).toHaveBeenCalledTimes(1));
mocks.metadataGeneration = 1;
@@ -574,7 +576,7 @@ describe("useDataGridActions", () => {
} as QueryTab;
const actions = useDataGridActions(computed(() => tab));
await actions.onSort("email", 2, "asc");
await actions.onSort(tab.id, "email", 2, "asc");
expect(mocks.executeTabSql).toHaveBeenCalledWith(
"tab-1",
@@ -644,7 +646,7 @@ describe("useDataGridActions", () => {
});
const actions = useDataGridActions(computed(() => tab));
await actions.onPaginate(300, 100);
await actions.onPaginate(tab.id, 300, 100);
expect(mocks.executeTabSql.mock.calls.map((call) => call[2].pagination)).toEqual([
{ offset: 100, limit: 100, sessionId: "cursor-1", clientSessionId: "client-1" },
@@ -677,7 +679,7 @@ describe("useDataGridActions", () => {
mocks.getConfig.mockImplementation((id: string) => ({ id, db_type: "postgres" }));
const actions = useDataGridActions(computed(() => tab));
await actions.onPaginate(100, 100);
await actions.onPaginate(tab.id, 100, 100);
expect(mocks.executeTabSql).toHaveBeenCalledWith(
"tab-1",
@@ -719,7 +721,7 @@ describe("useDataGridActions", () => {
});
const actions = useDataGridActions(computed(() => tab));
await actions.onSort("sort_value", 0, "asc", "status = 'active'");
await actions.onSort(tab.id, "sort_value", 0, "asc", "status = 'active'");
expect(tab.result?.rows.map((row) => row[0])).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
expect(tab.orderByInput).toBe('"sort_value" ASC');
@@ -753,7 +755,7 @@ describe("useDataGridActions", () => {
mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT sorted");
const actions = useDataGridActions(computed(() => tab));
await actions.onSort("sort_value", 0, direction, "status = 'active'", "database");
await actions.onSort(tab.id, "sort_value", 0, direction, "status = 'active'", "database");
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy, whereInput: "status = 'active'", limit: 7, offset: 0 }));
expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT sorted", expect.objectContaining({ pagination: { limit: 7, offset: 0 }, preserveTotalRowCountDuringExecution: true }));
@@ -781,7 +783,7 @@ describe("useDataGridActions", () => {
mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT unsorted");
const actions = useDataGridActions(computed(() => tab));
await actions.onSort("sort_value", 0, null, "status = 'active'", "database");
await actions.onSort(tab.id, "sort_value", 0, null, "status = 'active'", "database");
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy: undefined, whereInput: "status = 'active'", limit: 10, offset: 0 }));
expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT unsorted", expect.objectContaining({ pagination: { limit: 10, offset: 0 }, preserveTotalRowCountDuringExecution: true }));
@@ -813,7 +815,7 @@ describe("useDataGridActions", () => {
mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT sorted page 2");
const actions = useDataGridActions(computed(() => tab));
await actions.onPaginate(10, 10, "status = 'active'");
await actions.onPaginate(tab.id, 10, 10, "status = 'active'");
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy: '"sort_value" ASC', whereInput: "status = 'active'", limit: 10, offset: 10 }));
expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT sorted page 2", expect.objectContaining({ pagination: { offset: 10, limit: 10 }, preserveTotalRowCountDuringExecution: true }));
@@ -834,7 +836,7 @@ describe("useDataGridActions", () => {
});
const actions = useDataGridActions(computed(() => tab));
await actions.onSort("sort_value", 0, "asc", "status = 'active'", "local");
await actions.onSort(tab.id, "sort_value", 0, "asc", "status = 'active'", "local");
expect(mocks.sortTabResultLocally).toHaveBeenCalledWith("tab-1", "sort_value", 0, "asc");
expect(mocks.buildTableSelectSql).not.toHaveBeenCalled();
@@ -862,7 +864,7 @@ describe("useDataGridActions", () => {
mocks.buildSortedQuerySql.mockResolvedValueOnce({ ok: true, sql: "SELECT sorted" });
const actions = useDataGridActions(computed(() => tab));
await actions.onSort("sort_value", 0, "desc", undefined, "database");
await actions.onSort(tab.id, "sort_value", 0, "desc", undefined, "database");
expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT sorted", expect.objectContaining({ pagination: { limit: 10, offset: 0 }, preserveTotalRowCountDuringExecution: true }));
});
@@ -219,7 +219,7 @@ describe("useSqlExecution", () => {
expect(execution.showSqlParameterDialog.value).toBe(false);
expect(execution.sqlParameterNames.value).toEqual([]);
expect(executeCurrentSql).toHaveBeenCalledWith(sql, {});
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1" });
});
it("passes the selected statement's editor offset to the query store", async () => {
@@ -245,7 +245,7 @@ describe("useSqlExecution", () => {
await execution.tryExecute({ fullSql, selectedSql, cursorPos: selectionFrom, selectionFrom, selectionTo: fullSql.length - 1 });
expect(executeCurrentSql).toHaveBeenCalledWith(selectedSql, { sourceOffset: selectionFrom });
expect(executeCurrentSql).toHaveBeenCalledWith(selectedSql, { tabId: "tab-1", sourceOffset: selectionFrom });
});
it("expands preceding @set values in a selected shell-style statement", async () => {
@@ -273,7 +273,7 @@ describe("useSqlExecution", () => {
await execution.tryExecute({ fullSql, selectedSql, cursorPos: selectionFrom, selectionFrom, selectionTo });
expect(execution.showSqlParameterDialog.value).toBe(false);
expect(executeCurrentSql).toHaveBeenCalledWith("SELECT * FROM patrol WHERE post_id = '224';", {});
expect(executeCurrentSql).toHaveBeenCalledWith("SELECT * FROM patrol WHERE post_id = '224';", { tabId: "tab-1" });
});
it("opens the result table for a multi-statement batch by default", async () => {
@@ -671,7 +671,7 @@ SELECT @value AS Message;`;
await execution.tryExecuteInNewResultTab();
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { openInNewResultTab: true });
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1", openInNewResultTab: true });
});
it("does not record or refresh when a new-result execution restores the prior run", async () => {
@@ -723,7 +723,7 @@ SELECT @value AS Message;`;
await execution.onSqlParametersConfirm(resolvedSql);
expect(executeCurrentSql).toHaveBeenCalledWith(resolvedSql, { openInNewResultTab: true });
expect(executeCurrentSql).toHaveBeenCalledWith(resolvedSql, { tabId: "tab-1", openInNewResultTab: true });
});
it("executes the PostgreSQL ARRAY date-format query without opening the parameter dialog", async () => {
@@ -783,7 +783,7 @@ SELECT @value AS Message;`;
expect(execution.showSqlParameterDialog.value).toBe(false);
expect(execution.sqlParameterNames.value).toEqual([]);
expect(executeCurrentSql).toHaveBeenCalledWith(sql, {});
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1" });
});
it("executes Oracle database-link queries without opening the parameter dialog", async () => {
@@ -808,7 +808,7 @@ SELECT @value AS Message;`;
expect(execution.showSqlParameterDialog.value).toBe(false);
expect(execution.sqlParameterNames.value).toEqual([]);
expect(executeCurrentSql).toHaveBeenCalledWith(sql, {});
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1" });
});
it("sends native SET variables without client-side expansion", async () => {
@@ -872,7 +872,7 @@ SELECT @value AS Message;`;
expect(execution.showSqlParameterDialog.value).toBe(false);
expect(execution.sqlParameterNames.value).toEqual([]);
expect(executeCurrentSql).toHaveBeenCalledWith(sql, {});
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1" });
});
it("executes MySQL cursor procedures with compact labels without opening the parameter dialog", async () => {
@@ -912,7 +912,7 @@ SELECT @value AS Message;`;
expect(execution.showSqlParameterDialog.value).toBe(false);
expect(execution.sqlParameterNames.value).toEqual([]);
expect(executeCurrentSql).toHaveBeenCalledWith(sql, {});
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1" });
});
it.each([
@@ -975,7 +975,7 @@ SELECT @value AS Message;`;
expect(execution.showSqlParameterDialog.value).toBe(false);
expect(execution.sqlParameterNames.value).toEqual([]);
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { sourceOffset: 0, onExecutionStarted: expect.any(Function) });
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1", sourceOffset: 0, onExecutionStarted: expect.any(Function) });
});
it("sends Doris STRUCT DDL unchanged without opening the parameter dialog", async () => {
@@ -1008,7 +1008,7 @@ SELECT @value AS Message;`;
expect(execution.showSqlParameterDialog.value).toBe(false);
expect(execution.sqlParameterNames.value).toEqual([]);
expect(executeCurrentSql).toHaveBeenCalledWith(sql, {});
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1" });
});
it("records a later MySQL batch error and skips metadata refresh", async () => {
@@ -1287,7 +1287,7 @@ SELECT @value AS Message;`;
await execution.onDangerConfirm();
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { openInNewResultTab: true });
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1", openInNewResultTab: true });
});
it("keeps the active retained result when a new-result dangerous prompt is cancelled", async () => {
@@ -1493,7 +1493,7 @@ SELECT @value AS Message;`;
await execution.onDangerConfirm();
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { skipRedisSafetyCheck: false, openInNewResultTab: true });
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1", skipRedisSafetyCheck: false, openInNewResultTab: true });
});
it("distinguishes read-only and mutating Meilisearch REST requests", () => {
@@ -1,233 +0,0 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { TAB_DRAG_HORIZONTAL_THRESHOLD, useTabDrag } from "@/composables/useTabDrag";
function createDragHarness(onDrop = vi.fn(() => true)) {
const drag = useTabDrag(onDrop);
const source = document.createElement("div");
source.innerHTML = '<span class="truncate">Source</span>';
source.addEventListener("pointerdown", (event) => drag.startDrag(event as PointerEvent, "source"));
document.body.appendChild(source);
return { drag, source, onDrop };
}
function beginDrag(source: HTMLElement, x = 100, y = 20, pointerType = "mouse", buttons = 1, pointerId = 1) {
source.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0, buttons, clientX: x, clientY: y, pointerId, pointerType }));
}
function movePointer(x: number, y = 20, pointerType = "mouse", buttons = 1, pointerId = 1) {
document.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, buttons, clientX: x, clientY: y, pointerId, pointerType }));
}
function releasePointer(x = 0, y = 0, pointerId = 1) {
document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, clientX: x, clientY: y, pointerId, screenX: x, screenY: y }));
}
function cancelPointer(pointerId = 1) {
document.dispatchEvent(new PointerEvent("pointercancel", { bubbles: true, pointerId }));
}
function enterDropTarget(drag: ReturnType<typeof useTabDrag>, tabId = "target", x = 10) {
const target = document.createElement("div");
target.getBoundingClientRect = () => ({ x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 28, width: 100, height: 28, toJSON: () => ({}) });
target.addEventListener("mousemove", (event) => drag.updateTarget(event, tabId));
document.body.appendChild(target);
target.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: x, clientY: 20 }));
}
afterEach(() => {
releasePointer();
document.body.innerHTML = "";
document.body.style.cursor = "";
document.body.style.userSelect = "";
});
describe("useTabDrag", () => {
it("ignores sub-threshold horizontal movement and vertical pointer jitter", () => {
const { drag, source, onDrop } = createDragHarness();
beginDrag(source);
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD - 1, 120);
expect(drag.state.active).toBe(false);
expect(drag.state.suppressClick).toBe(false);
expect(onDrop).not.toHaveBeenCalled();
});
it("does not suppress the click when a drag gesture has no valid drop target", () => {
const { drag, source, onDrop } = createDragHarness();
beginDrag(source);
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
expect(drag.state.active).toBe(true);
releasePointer();
expect(onDrop).not.toHaveBeenCalled();
expect(drag.state.suppressClick).toBe(false);
});
it("does not suppress the click when the drop callback reports no reorder", () => {
const onDrop = vi.fn(() => false);
const { drag, source } = createDragHarness(onDrop);
beginDrag(source);
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
enterDropTarget(drag);
releasePointer();
expect(onDrop).toHaveBeenCalledWith("source", "target", "before");
expect(drag.state.suppressClick).toBe(false);
});
it("suppresses the click only after a completed reorder", () => {
const { drag, source, onDrop } = createDragHarness();
beginDrag(source);
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
enterDropTarget(drag, "target", 90);
releasePointer();
expect(onDrop).toHaveBeenCalledWith("source", "target", "after");
expect(drag.state.suppressClick).toBe(true);
beginDrag(source);
expect(drag.state.suppressClick).toBe(false);
});
it("clears completed-drag click suppression on a later trackpad tap", () => {
const { drag, source } = createDragHarness();
beginDrag(source);
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
enterDropTarget(drag, "target", 90);
releasePointer();
expect(drag.state.suppressClick).toBe(true);
beginDrag(source, 100, 20, "mouse", 0);
expect(drag.state.active).toBe(false);
expect(drag.state.suppressClick).toBe(false);
});
it("ignores 18px of jitter, matching a typical touchscreen tap reported as generic mouse input", () => {
const { drag, source, onDrop } = createDragHarness();
beginDrag(source);
movePointer(100 - 18, 24);
expect(drag.state.active).toBe(false);
expect(drag.state.suppressClick).toBe(false);
expect(onDrop).not.toHaveBeenCalled();
});
it("does not arm a drag when the primary button is not held", () => {
const { drag, source, onDrop } = createDragHarness();
beginDrag(source, 100, 20, "mouse", 0);
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD, 20, "mouse", 0);
releasePointer();
expect(drag.state.active).toBe(false);
expect(onDrop).not.toHaveBeenCalled();
});
it("cancels a pending drag when the primary button is released before movement", () => {
const { drag, source, onDrop } = createDragHarness();
beginDrag(source);
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD, 20, "mouse", 0);
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD + 10);
expect(drag.state.active).toBe(false);
expect(onDrop).not.toHaveBeenCalled();
});
it("cancels an active drag when the primary button is no longer held", () => {
const { drag, source, onDrop } = createDragHarness();
beginDrag(source);
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
expect(drag.state.active).toBe(true);
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD + 10, 20, "mouse", 0);
expect(drag.state.active).toBe(false);
expect(drag.state.draggedId).toBe(null);
expect(document.body.style.cursor).toBe("");
expect(document.body.style.userSelect).toBe("");
expect(onDrop).not.toHaveBeenCalled();
});
it("ignores movement from a different pointer", () => {
const { drag, source, onDrop } = createDragHarness();
beginDrag(source, 100, 20, "mouse", 1, 7);
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD, 20, "mouse", 1, 8);
expect(drag.state.active).toBe(false);
expect(onDrop).not.toHaveBeenCalled();
releasePointer(0, 0, 7);
});
it("never arms a drag for touch input, even past the horizontal threshold", () => {
const { drag, source, onDrop } = createDragHarness();
beginDrag(source, 100, 20, "touch");
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD + 20, 20, "touch");
expect(drag.state.active).toBe(false);
expect(drag.state.suppressClick).toBe(false);
expect(onDrop).not.toHaveBeenCalled();
});
it("detaches only when an armed drag is released outside the tab strip", () => {
const onDetach = vi.fn(() => true);
const drag = useTabDrag(
vi.fn(() => true),
onDetach,
);
drag.setDetachBoundsProvider(() => ({ left: 0, top: 0, right: 300, bottom: 40 }) as DOMRect);
const source = document.createElement("div");
source.innerHTML = '<span class="truncate">Source</span>';
source.addEventListener("pointerdown", (event) => drag.startDrag(event as PointerEvent, "source"));
document.body.appendChild(source);
beginDrag(source);
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
releasePointer(500, 500);
expect(onDetach).toHaveBeenCalledWith("source", { x: 500, y: 500 });
expect(drag.state.active).toBe(false);
expect(drag.state.suppressClick).toBe(true);
drag.setDetachBoundsProvider(null);
});
it("clears an armed drag when the pointer is cancelled", () => {
const { drag, source } = createDragHarness();
beginDrag(source);
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
expect(drag.state.active).toBe(true);
cancelPointer();
expect(drag.state.active).toBe(false);
expect(drag.state.draggedId).toBe(null);
expect(drag.state.suppressClick).toBe(false);
});
it("does not detach when the pointer is released inside the tab strip", () => {
const onDetach = vi.fn(() => true);
const onDrop = vi.fn(() => false);
const drag = useTabDrag(onDrop, onDetach);
drag.setDetachBoundsProvider(() => ({ left: 0, top: 0, right: 300, bottom: 40 }) as DOMRect);
const source = document.createElement("div");
source.innerHTML = '<span class="truncate">Source</span>';
source.addEventListener("pointerdown", (event) => drag.startDrag(event as PointerEvent, "source"));
document.body.appendChild(source);
beginDrag(source);
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
releasePointer();
expect(onDetach).not.toHaveBeenCalled();
expect(onDrop).not.toHaveBeenCalled();
drag.setDetachBoundsProvider(null);
});
});
@@ -0,0 +1,133 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, ref } from "vue";
import type { Ref, WatchSource } from "vue";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { useToolbarOverflow } from "../useToolbarOverflow";
import type { EditorToolbarTier } from "@/lib/tabs/editorToolbarLayout";
function createHarness(revalidateSources: WatchSource[] = []) {
const rootRef = ref<HTMLElement | null>(null);
let tierRef: Ref<EditorToolbarTier> | undefined;
let measureFn: () => void = () => {};
const app = createApp(
defineComponent({
setup() {
const { tier, measure } = useToolbarOverflow(rootRef, revalidateSources);
tierRef = tier;
measureFn = measure;
return () => h("div");
},
}),
);
app.mount(document.createElement("div"));
return { rootRef, tierRef: tierRef!, measure: () => measureFn() };
}
/**
* Fake toolbar row whose scrollWidth shrinks once a tier is condensed —
* mirroring real rows where controls move into the overflow menu, so the
* measure loop can converge instead of running to the max tier.
*/
function fakeRow(clientWidth: number, contentWidthAtTier: (tier: EditorToolbarTier) => number, tierSource: Ref<EditorToolbarTier>): HTMLElement {
const element = document.createElement("div");
Object.defineProperty(element, "clientWidth", { value: clientWidth, configurable: true });
Object.defineProperty(element, "scrollWidth", { configurable: true, get: () => contentWidthAtTier(tierSource.value) });
return element;
}
async function flush(ticks = 5) {
for (let i = 0; i < ticks; i += 1) {
await nextTick();
}
}
describe("useToolbarOverflow", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
afterEach(() => {
document.body.innerHTML = "";
});
it("steps up while the row content overflows and keeps the tier when it merely fits again", async () => {
const { rootRef, tierRef, measure } = createHarness();
rootRef.value = fakeRow(200, (tier) => (tier >= 1 ? 180 : 260), tierRef);
await flush();
expect(tierRef.value).toBe(1);
// Widening without enough growth keeps the condensed tier: the row fits
// (slack 20px < 48px) and the pane grew 0px since the condensation. The
// explicit measure() stands in for the ResizeObserver event that a real
// browser fires after the box resize.
Object.defineProperty(rootRef.value!, "clientWidth", { value: 220, configurable: true });
measure();
expect(tierRef.value).toBe(1);
});
it("condenses tier by tier and caps at the max tier while still overflowing", async () => {
const { rootRef, tierRef } = createHarness();
rootRef.value = fakeRow(160, () => 400, tierRef);
await flush();
expect(tierRef.value).toBe(3);
});
it("steps down only with real slack and meaningful pane growth", async () => {
const { rootRef, tierRef, measure } = createHarness();
rootRef.value = fakeRow(200, (tier) => (tier >= 1 ? 180 : 260), tierRef);
await flush();
expect(tierRef.value).toBe(1);
// Grew by 10px only: below the 64px growth hysteresis, no step down even
// though the condensed row now has slack.
Object.defineProperty(rootRef.value!, "clientWidth", { value: 210, configurable: true });
measure();
expect(tierRef.value).toBe(1);
// Grew by 100px with 70px slack: both step-down thresholds pass.
Object.defineProperty(rootRef.value!, "clientWidth", { value: 300, configurable: true });
measure();
expect(tierRef.value).toBe(0);
});
it("re-measures when a revalidate source changes the control set", async () => {
const flag = ref(0);
const { rootRef, tierRef } = createHarness([() => flag.value]);
let contentGrew = false;
rootRef.value = fakeRow(200, (tier) => (tier >= 1 ? 180 : contentGrew ? 260 : 180), tierRef);
await flush();
expect(tierRef.value).toBe(0);
// A conditional control appeared: the content extent grew without the
// toolbar box resizing (no ResizeObserver event) — only the source watch
// re-measures this.
contentGrew = true;
flag.value += 1;
await flush();
expect(tierRef.value).toBe(1);
});
it("starts from the full layout when a row re-attaches instead of inheriting a stale tier", async () => {
const { rootRef, tierRef } = createHarness();
rootRef.value = fakeRow(200, () => 400, tierRef);
await flush();
expect(tierRef.value).toBe(3);
rootRef.value = fakeRow(300, () => 230, tierRef);
await flush();
expect(tierRef.value).toBe(0);
});
it("keeps the current tier while the row is unmeasured", async () => {
const { rootRef, tierRef } = createHarness();
rootRef.value = fakeRow(0, () => 0, tierRef);
await flush();
expect(tierRef.value).toBe(0);
});
});
@@ -45,6 +45,21 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
// A data-grid action names the tab that emitted the event, so a queued
// or late-routed event cannot fall back to whichever tab is active now.
function resolveActionTab(tabId: string | undefined): QueryTab | undefined {
if (!tabId) {
return activeTab.value;
}
const fromStore = queryStore.tabs.find((candidate) => candidate.id === tabId);
if (fromStore) {
return fromStore;
}
// Hosts may hold the acting tab outside the store array; identity comes
// from the captured id either way.
return activeTab.value?.id === tabId ? activeTab.value : undefined;
}
function activeQueryTargetOptions(tab: QueryTab) {
const executionTarget = queryStore.activeResultExecutionTarget(tab.id);
if (!executionTarget) return {};
@@ -162,15 +177,15 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
return true;
}
async function onExecuteSql(sql: string) {
const tab = activeTab.value;
async function onExecuteSql(sql: string, tabId?: string) {
const tab = resolveActionTab(tabId);
if (!tab) return;
queryStore.updateSql(tab.id, sql);
await queryStore.executeTabSql(tab.id, sql, { preserveResultDuringExecution: true });
}
async function onReloadData(sql?: string, _searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent) {
const tab = activeTab.value;
async function onReloadData(tabId: string | undefined, sql?: string, _searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent) {
const tab = resolveActionTab(tabId);
if (!tab) return;
const traceId = uuid().slice(0, 8);
const startedAt = performance.now();
@@ -328,8 +343,8 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
await queryStore.executeCurrentTab();
}
async function onPaginate(offset: number, limit: number, whereInput?: string, orderBy?: string) {
const tab = activeTab.value;
async function onPaginate(tabId: string | undefined, offset: number, limit: number, whereInput?: string, orderBy?: string) {
const tab = resolveActionTab(tabId);
if (!tab) return;
const appendResult = settingsStore.editorSettings.infiniteScroll && offset > 0 && offset === tab.result?.rows.length;
const appendOptions = appendResult
@@ -462,8 +477,8 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
});
}
async function onSort(column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode: DataGridSortMode = "database") {
const tab = activeTab.value;
async function onSort(tabId: string | undefined, column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode: DataGridSortMode = "database") {
const tab = resolveActionTab(tabId);
if (!tab) return;
tab.resultSortColumn = direction ? column : undefined;
tab.resultSortColumnIndex = direction ? columnIndex : undefined;
@@ -43,9 +43,9 @@ const AUTO_SCROLL_EDGE_SIZE = 40;
const AUTO_SCROLL_MAX_SPEED = 28;
// Trackpad/mouse jitter during a plain click can move the pointer a few pixels between
// mousedown and mouseup. Without a minimum drag distance, that jitter reads as an
// intentional drag and turns a single click into a multi-cell/row selection. Guards the
// same class of gesture as TAB_DRAG_HORIZONTAL_THRESHOLD in useTabDrag.ts, though that one
// is tuned higher to also absorb touchscreen tap jitter, which doesn't apply here.
// intentional drag and turns a single click into a multi-cell/row selection. Same class
// of gesture as the group tab bar's horizontal tab-drag threshold, though that one is
// tuned higher to also absorb touchscreen tap jitter, which doesn't apply here.
const GRID_SELECTION_DRAG_THRESHOLD_PX = 12;
type RowSelectionOperation = "replace" | "add" | "remove";
+206 -64
View File
@@ -34,6 +34,18 @@ const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i;
interface SqlExecutionOptions {
openInNewResultTab?: boolean;
editorViewportRequestId?: number;
/** Tab that owns this execution request; resolved once, never re-read from the global active tab. */
tabId?: string;
}
/**
* Targeting context captured synchronously when an execution request starts.
* Async resume points (danger / parameter dialogs, awaits) must keep using it
* instead of re-reading the global active tab or active connection.
*/
interface SqlExecutionContext {
tabId: string;
connection: ConnectionConfig | undefined;
}
interface TargetSqlExecutionInput {
@@ -112,10 +124,10 @@ export function useSqlExecution(deps: {
activeTab: ComputedRef<QueryTab | undefined>;
activeConnection: ComputedRef<ConnectionConfig | undefined>;
executableSql: ComputedRef<string>;
resolveExecutableSql?: (snapshot?: SqlExecutionSnapshot) => Promise<string>;
resolveExecutableSql?: (snapshot?: SqlExecutionSnapshot, tab?: QueryTab) => Promise<string>;
activeOutputView: Ref<"result" | "summary" | "explain" | "chart" | "messages">;
blockDangerousRedisCommands?: Ref<boolean>;
onMissingDatabase?: () => void;
onMissingDatabase?: (tabId?: string) => void;
requestDangerConfirmation?: (request: SqlExecutionDangerRequest) => Promise<boolean>;
onExecutionStarted?: (editorViewportRequestId: number) => void;
onExecutionCancelled?: (editorViewportRequestId: number) => void;
@@ -144,19 +156,47 @@ export function useSqlExecution(deps: {
const pendingOpenInNewResultTab = ref(false);
const pendingSqlParameterEditorViewportRequestId = ref<number | undefined>();
const pendingDangerEditorViewportRequestId = ref<number | undefined>();
const pendingDangerTabId = ref<string | undefined>();
const pendingSqlParameterTabId = ref<string | undefined>();
let pendingSqlParameterContinuation: ((sql: string, sourceOffset?: number) => Promise<void> | void) | undefined;
function cancelEditorViewportRequest(editorViewportRequestId?: number) {
if (editorViewportRequestId !== undefined) deps.onExecutionCancelled?.(editorViewportRequestId);
}
async function resolvedExecutableSql(source?: SqlExecutionOverride): Promise<{ sql: string; sourceOffset?: number; editorViewportRequestId?: number }> {
const atSetEnabled = resolveSqlVariableSyntaxToggles(settingsStore.editorSettings.sqlVariableSyntaxOverrides, deps.activeConnection.value?.db_type, settingsStore.editorSettings.sqlVariableSubstitutionEnabled).atSet;
const databaseType = effectiveDatabaseTypeForConnection(deps.activeConnection.value) ?? deps.activeConnection.value?.db_type;
function resolveExecutionTab(tabId: string | undefined): QueryTab | undefined {
if (!tabId) {
return deps.activeTab.value;
}
const fromStore = queryStore.tabs.find((tab) => tab.id === tabId);
if (fromStore) {
return fromStore;
}
// The host may hold the acting tab outside the store array (multi-db
// workers, hosts under test); identity still comes from the captured id.
return deps.activeTab.value?.id === tabId ? deps.activeTab.value : undefined;
}
function resolveExecutionConnection(tabId: string | undefined): ConnectionConfig | undefined {
const tab = resolveExecutionTab(tabId);
return (tab ? connectionStore.getConfig(tab.connectionId) : undefined) ?? deps.activeConnection.value;
}
function captureExecutionContext(tabId?: string): SqlExecutionContext | undefined {
const tab = resolveExecutionTab(tabId);
if (!tab) {
return undefined;
}
return { tabId: tab.id, connection: resolveExecutionConnection(tabId) };
}
async function resolvedExecutableSql(source: SqlExecutionOverride | undefined, context: SqlExecutionContext): Promise<{ sql: string; sourceOffset?: number; editorViewportRequestId?: number }> {
const atSetEnabled = resolveSqlVariableSyntaxToggles(settingsStore.editorSettings.sqlVariableSyntaxOverrides, context.connection?.db_type, settingsStore.editorSettings.sqlVariableSubstitutionEnabled).atSet;
const databaseType = effectiveDatabaseTypeForConnection(context.connection) ?? context.connection?.db_type;
const expand = (sql: string, declarationSql?: string) => (atSetEnabled ? expandSqlVariables(sql, { declarationSql, databaseType }).sql : sql);
if (typeof source === "string") return { sql: expand(source) };
const resolved = deps.resolveExecutableSql ? await deps.resolveExecutableSql(source) : isSqlExecutionSnapshot(source) ? resolveExecutableSql(source.fullSql, source.selectedSql, { cursorPos: source.cursorPos }) : deps.executableSql.value;
const resolved = deps.resolveExecutableSql ? await deps.resolveExecutableSql(source, resolveExecutionTab(context.tabId)) : isSqlExecutionSnapshot(source) ? resolveExecutableSql(source.fullSql, source.selectedSql, { cursorPos: source.cursorPos }) : deps.executableSql.value;
const declarationSql = isSqlExecutionSnapshot(source) ? source.fullSql.slice(0, source.selectionTo) : resolved;
const sql = expand(resolved, declarationSql);
const editorViewportRequestId = isSqlExecutionSnapshot(source) ? source.editorViewportRequestId : undefined;
@@ -167,33 +207,49 @@ export function useSqlExecution(deps: {
}
async function tryExecute(sqlOverride?: SqlExecutionOverride, options: SqlExecutionOptions = {}) {
const tab = deps.activeTab.value;
const { sql, sourceOffset, editorViewportRequestId } = await resolvedExecutableSql(sqlOverride);
const executionOptions = { ...options, editorViewportRequestId };
if (!tab || !sql.trim()) {
const context = captureExecutionContext(options.tabId);
if (!context) {
cancelEditorViewportRequest(options.editorViewportRequestId);
return;
}
const { sql, sourceOffset, editorViewportRequestId } = await resolvedExecutableSql(sqlOverride, context);
const executionOptions = { ...options, editorViewportRequestId, tabId: context.tabId };
if (!sql.trim()) {
cancelEditorViewportRequest(editorViewportRequestId);
return;
}
if (requiresDatabaseSelection(tab, deps.activeConnection.value, sql)) {
deps.onMissingDatabase?.();
const tab = resolveExecutionTab(context.tabId);
if (!tab) {
cancelEditorViewportRequest(editorViewportRequestId);
return;
}
if (supportsSqlTemplateParameters(deps.activeConnection.value, sql) && prepareSqlParameterDialog(sql, sourceOffset, executionOptions)) return;
await continueExecute(sql, sourceOffset, executionOptions);
if (requiresDatabaseSelection(tab, context.connection, sql)) {
deps.onMissingDatabase?.(context.tabId);
cancelEditorViewportRequest(editorViewportRequestId);
return;
}
if (supportsSqlTemplateParameters(context.connection, sql) && prepareSqlParameterDialog(sql, sourceOffset, executionOptions)) {
return;
}
await continueExecute(context, sql, sourceOffset, executionOptions);
}
function tryExecuteInNewResultTab(sqlOverride?: SqlExecutionOverride) {
return tryExecute(sqlOverride, { openInNewResultTab: true });
function tryExecuteInNewResultTab(sqlOverride?: SqlExecutionOverride, options: SqlExecutionOptions = {}) {
return tryExecute(sqlOverride, { ...options, openInNewResultTab: true });
}
async function continueExecute(sql: string, sourceOffset?: number, options: SqlExecutionOptions = {}) {
if (!(await ensureReadOnlyWriteAccess({ connection: deps.activeConnection.value, sql, source: t("production.sourceSqlEditor") }))) {
async function continueExecute(context: SqlExecutionContext, sql: string, sourceOffset?: number, options: SqlExecutionOptions = {}) {
const tab = resolveExecutionTab(context.tabId);
if (!tab) {
cancelEditorViewportRequest(options.editorViewportRequestId);
return;
}
if (!(await ensureReadOnlyWriteAccess({ connection: context.connection, sql, source: t("production.sourceSqlEditor") }))) {
cancelEditorViewportRequest(options.editorViewportRequestId);
return;
}
// Redis: block dangerous commands when toggle is on (scan entire batch for highest safety level)
if (deps.activeConnection.value?.db_type === "redis" && deps.blockDangerousRedisCommands?.value !== false) {
if (context.connection?.db_type === "redis" && deps.blockDangerousRedisCommands?.value !== false) {
const commands = sql
.split("\n")
.map((line) => line.trim())
@@ -221,32 +277,37 @@ export function useSqlExecution(deps: {
pendingDangerSourceOffset.value = sourceOffset;
pendingOpenInNewResultTab.value = options.openInNewResultTab === true;
pendingDangerEditorViewportRequestId.value = options.editorViewportRequestId;
pendingDangerTabId.value = context.tabId;
suppressDangerConfirm.value = false;
showDangerDialog.value = true;
return;
}
}
const productionAssessment = assessProductionSql(sql, deps.activeConnection.value, deps.activeTab.value?.database);
const productionAssessment = assessProductionSql(sql, context.connection, tab.database);
if (productionAssessment.active && productionAssessment.isMutation) {
// Production writes always need a new explicit decision; editor preferences cannot suppress this gate.
const confirmed = await productionSafetyStore.requestConfirmation({
sql,
connectionName: deps.activeConnection.value?.name,
database: deps.activeTab.value?.database,
connectionName: context.connection?.name,
database: tab.database,
productionDatabases: productionAssessment.databases,
source: t("production.sourceSqlEditor"),
});
if (confirmed) await doExecute(sql, sourceOffset, options);
else cancelEditorViewportRequest(options.editorViewportRequestId);
if (confirmed) {
await doExecute(sql, sourceOffset, options);
} else {
cancelEditorViewportRequest(options.editorViewportRequestId);
}
return;
}
if (isDangerousSql(sql, deps.activeConnection.value?.db_type) && settingsStore.editorSettings.confirmDangerousSqlExecution) {
if (isDangerousSql(sql, context.connection?.db_type) && settingsStore.editorSettings.confirmDangerousSqlExecution) {
dangerSql.value = sql;
pendingDangerSql.value = sql;
pendingDangerKind.value = "sql";
pendingDangerSourceOffset.value = sourceOffset;
pendingOpenInNewResultTab.value = options.openInNewResultTab === true;
pendingDangerEditorViewportRequestId.value = options.editorViewportRequestId;
pendingDangerTabId.value = context.tabId;
suppressDangerConfirm.value = false;
showDangerDialog.value = true;
} else {
@@ -255,12 +316,14 @@ export function useSqlExecution(deps: {
}
function prepareSqlParameterDialog(sql: string, sourceOffset?: number, options: SqlExecutionOptions = {}, continuation?: (sql: string, sourceOffset?: number) => Promise<void> | void): boolean {
const connection = deps.activeConnection.value;
const connection = resolveExecutionConnection(options.tabId);
const databaseType = effectiveDatabaseTypeForConnection(connection) ?? connection?.db_type;
const toggles = resolveSqlVariableSyntaxToggles(settingsStore.editorSettings.sqlVariableSyntaxOverrides, databaseType, settingsStore.editorSettings.sqlVariableSubstitutionEnabled);
const enabledSyntaxes = enabledSqlParameterSyntaxes(toggles);
const parameters = extractSqlParameterDescriptors(sql, { databaseType, enabledSyntaxes });
if (!parameters.length) return false;
if (!parameters.length) {
return false;
}
sqlParameterSourceSql.value = sql;
sqlParameterNames.value = parameters;
sqlParameterDatabaseType.value = databaseType;
@@ -268,16 +331,22 @@ export function useSqlExecution(deps: {
pendingSourceOffset.value = sourceOffset;
pendingOpenInNewResultTab.value = options.openInNewResultTab === true;
pendingSqlParameterEditorViewportRequestId.value = options.editorViewportRequestId;
pendingSqlParameterTabId.value = options.tabId;
pendingSqlParameterContinuation = continuation;
showSqlParameterDialog.value = true;
return true;
}
async function prepareMultiExecute(onReady: (sql: string, sourceOffset?: number) => Promise<void> | void): Promise<boolean> {
const tab = deps.activeTab.value;
const { sql, sourceOffset } = await resolvedExecutableSql();
if (!tab || !sql.trim()) return false;
if (supportsSqlTemplateParameters(deps.activeConnection.value, sql) && prepareSqlParameterDialog(sql, sourceOffset, {}, onReady)) return true;
const context = captureExecutionContext();
if (!context) {
return false;
}
const { sql, sourceOffset } = await resolvedExecutableSql(undefined, context);
if (!sql.trim()) return false;
if (supportsSqlTemplateParameters(context.connection, sql) && prepareSqlParameterDialog(sql, sourceOffset, { tabId: context.tabId }, onReady)) {
return true;
}
await onReady(sql, sourceOffset);
return false;
}
@@ -300,26 +369,41 @@ export function useSqlExecution(deps: {
async function doExecute(sql?: string, sourceOffset?: number, options: SqlExecutionOptions = {}) {
if (sql === undefined) {
({ sql, sourceOffset } = await resolvedExecutableSql());
const context = captureExecutionContext(options.tabId);
if (!context) {
cancelEditorViewportRequest(options.editorViewportRequestId);
return;
}
({ sql, sourceOffset } = await resolvedExecutableSql(undefined, context));
}
const tab = deps.activeTab.value;
if (!tab || !sql.trim()) {
const executionTabId = options.tabId ?? deps.activeTab.value?.id;
if (!executionTabId || !sql || !sql.trim()) {
cancelEditorViewportRequest(options.editorViewportRequestId);
return;
}
const tab = resolveExecutionTab(executionTabId);
if (!tab) {
cancelEditorViewportRequest(options.editorViewportRequestId);
return;
}
const executionConnection = connectionStore.getConfig(tab.connectionId) ?? deps.activeConnection.value;
const executionDatabaseType = executionConnection?.db_type;
if (requiresDatabaseSelection(tab, executionConnection, sql)) {
deps.onMissingDatabase?.();
deps.onMissingDatabase?.(executionTabId);
cancelEditorViewportRequest(options.editorViewportRequestId);
return;
}
const statementCount = splitSqlStatementRanges(sql, executionDatabaseType).length;
deps.activeOutputView.value = statementCount > 1 ? settingsStore.editorSettings.multiStatementDefaultView : "result";
// Output-view switching belongs to the tab the user is looking at — both
// when the query starts and when it finishes.
if (deps.activeTab.value?.id === executionTabId) {
deps.activeOutputView.value = statementCount > 1 ? settingsStore.editorSettings.multiStatementDefaultView : "result";
}
const connName = executionConnection?.name || "";
const start = Date.now();
const isRedis = executionDatabaseType === "redis";
const producedResult = await queryStore.executeCurrentSql(sql, {
tabId: executionTabId,
...(isRedis ? { skipRedisSafetyCheck: deps.blockDangerousRedisCommands?.value === false } : {}),
...(sourceOffset !== undefined ? { sourceOffset } : {}),
...(options.openInNewResultTab ? { openInNewResultTab: true } : {}),
@@ -329,15 +413,21 @@ export function useSqlExecution(deps: {
cancelEditorViewportRequest(options.editorViewportRequestId);
return;
}
const executionTabStillActive = deps.activeTab.value?.id === tab.id;
const executionTabStillActive = deps.activeTab.value?.id === executionTabId;
const sqlServerMessageResultIndex = executionDatabaseType === "sqlserver" ? tab.results?.findIndex((result) => result.server_message === true) : undefined;
if (sqlServerMessageResultIndex !== undefined && sqlServerMessageResultIndex >= 0) {
focusSqlServerDataResult(tab.id, executionDatabaseType, tab);
if (executionTabStillActive) deps.activeOutputView.value = "result";
if (executionTabStillActive) {
deps.activeOutputView.value = "result";
}
} else if (executionDatabaseType === "sqlserver" && tab.result?.server_message === true) {
if (executionTabStillActive) deps.activeOutputView.value = "result";
if (executionTabStillActive) {
deps.activeOutputView.value = "result";
}
} else if (tab.result && !tab.result.columns.length && !tab.results?.some((result) => result.columns.length > 0)) {
if (executionTabStillActive) deps.activeOutputView.value = statementCount === 1 ? defaultViewForResult(tab.result) : "summary";
if (executionTabStillActive) {
deps.activeOutputView.value = statementCount === 1 ? defaultViewForResult(tab.result) : "summary";
}
}
const elapsed = Date.now() - start;
const failure = firstQueryExecutionError(tab);
@@ -529,50 +619,83 @@ export function useSqlExecution(deps: {
}
}
function cancelActiveExecution() {
const tab = deps.activeTab.value;
if (!tab) return;
if (tab.isExecuting) void queryStore.cancelTabExecution(tab.id);
else if (tab.isExplaining) void queryStore.cancelTabExplain(tab.id);
/**
* Opens the danger dialog for an App-level flow (AI auto-execution) that has
* already made its own gating decision. The acting tab id is captured here so
* the confirmation resumes against the requesting tab, not whichever tab is
* active when the user confirms.
*/
function requestDangerConfirmation(sql: string, tabId?: string) {
dangerSql.value = sql;
pendingDangerSql.value = sql;
pendingDangerKind.value = "sql";
pendingOpenInNewResultTab.value = false;
pendingDangerTabId.value = tabId;
suppressDangerConfirm.value = false;
showDangerDialog.value = true;
}
function cancelActiveExecution(tabId?: string) {
const tab = resolveExecutionTab(tabId);
if (!tab) {
return;
}
if (tab.isExecuting) {
void queryStore.cancelTabExecution(tab.id);
} else if (tab.isExplaining) {
void queryStore.cancelTabExplain(tab.id);
}
}
function explainReasonMessage(reason: string): string {
if (reason === "unsupported") return t("explain.unsupported");
if (reason === "unsafe") return t("explain.unsafe");
if (reason === "unsupported") {
return t("explain.unsupported");
}
if (reason === "unsafe") {
return t("explain.unsafe");
}
return t("explain.emptySql");
}
async function runExplain(sql: string) {
const tab = deps.activeTab.value;
async function runExplain(sql: string, context: SqlExecutionContext) {
const tab = resolveExecutionTab(context.tabId);
if (!tab || !sql.trim()) {
toast(t("explain.emptySql"));
return;
}
deps.activeOutputView.value = "explain";
const connection = deps.activeConnection.value;
const databaseType = effectiveDatabaseTypeForConnection(connection) ?? connection?.db_type;
if (deps.activeTab.value?.id === tab.id) {
deps.activeOutputView.value = "explain";
}
const databaseType = effectiveDatabaseTypeForConnection(context.connection) ?? context.connection?.db_type;
const result = await queryStore.explainTabSql(tab.id, sql, databaseType, explainMode.value);
if (!result.ok) {
toast(explainReasonMessage(result.reason), 5000);
return;
}
const current = deps.activeTab.value;
if (current?.explainError) toast(current.explainError, 5000);
const current = resolveExecutionTab(context.tabId);
if (current?.explainError) {
toast(current.explainError, 5000);
}
}
async function tryExplain(sqlOverride?: SqlExecutionOverride) {
const tab = deps.activeTab.value;
const { sql, sourceOffset } = await resolvedExecutableSql(sqlOverride);
if (!tab || !sql.trim()) {
async function tryExplain(sqlOverride?: SqlExecutionOverride, options: SqlExecutionOptions = {}) {
const context = captureExecutionContext(options.tabId);
if (!context) {
return;
}
const { sql, sourceOffset } = await resolvedExecutableSql(sqlOverride, context);
if (!sql.trim()) {
toast(t("explain.emptySql"));
return;
}
// Resolve SQL template variables exactly like tryExecute so EXPLAIN never runs the raw
// placeholders (e.g. `EXPLAIN (FORMAT JSON) SELECT :var;` fails on PostgreSQL).
if (supportsSqlTemplateParameters(deps.activeConnection.value, sql) && prepareSqlParameterDialog(sql, sourceOffset, {}, (resolvedSql) => runExplain(resolvedSql))) return;
await runExplain(sql);
if (supportsSqlTemplateParameters(context.connection, sql) && prepareSqlParameterDialog(sql, sourceOffset, { tabId: context.tabId }, (resolvedSql) => runExplain(resolvedSql, context))) {
return;
}
await runExplain(sql, context);
}
async function onDangerConfirm() {
@@ -581,25 +704,33 @@ export function useSqlExecution(deps: {
const kind = pendingDangerKind.value;
const openInNewResultTab = pendingOpenInNewResultTab.value;
const editorViewportRequestId = pendingDangerEditorViewportRequestId.value;
const tabId = pendingDangerTabId.value;
pendingDangerSql.value = "";
pendingDangerSourceOffset.value = undefined;
pendingDangerKind.value = "sql";
pendingOpenInNewResultTab.value = false;
pendingDangerEditorViewportRequestId.value = undefined;
pendingDangerTabId.value = undefined;
if (suppressDangerConfirm.value && kind === "sql") {
settingsStore.updateEditorSettings({ confirmDangerousSqlExecution: false });
}
suppressDangerConfirm.value = false;
if (!(await ensureReadOnlyWriteAccess({ connection: deps.activeConnection.value, sql, source: t("production.sourceSqlEditor") }))) {
const tab = resolveExecutionTab(tabId);
if (!tab) {
return;
}
const connection = connectionStore.getConfig(tab.connectionId);
if (!(await ensureReadOnlyWriteAccess({ connection, sql, source: t("production.sourceSqlEditor") }))) {
cancelEditorViewportRequest(editorViewportRequestId);
return;
}
await doExecute(sql, sourceOffset, { openInNewResultTab, editorViewportRequestId });
await doExecute(sql, sourceOffset, { openInNewResultTab, editorViewportRequestId, tabId: tab.id });
}
async function onSqlParametersConfirm(sql: string) {
const openInNewResultTab = pendingOpenInNewResultTab.value;
const editorViewportRequestId = pendingSqlParameterEditorViewportRequestId.value;
const tabId = pendingSqlParameterTabId.value;
showSqlParameterDialog.value = false;
sqlParameterSourceSql.value = "";
sqlParameterNames.value = [];
@@ -609,10 +740,18 @@ export function useSqlExecution(deps: {
pendingSourceOffset.value = undefined;
pendingOpenInNewResultTab.value = false;
pendingSqlParameterEditorViewportRequestId.value = undefined;
pendingSqlParameterTabId.value = undefined;
const continuation = pendingSqlParameterContinuation;
pendingSqlParameterContinuation = undefined;
if (continuation) await continuation(sql, sourceOffset);
else await continueExecute(sql, sourceOffset, { openInNewResultTab, editorViewportRequestId });
if (continuation) {
await continuation(sql, sourceOffset);
return;
}
const context = captureExecutionContext(tabId);
if (!context) {
return;
}
await continueExecute(context, sql, sourceOffset, { openInNewResultTab, editorViewportRequestId, tabId: context.tabId });
}
watch(showSqlParameterDialog, (open) => {
@@ -625,6 +764,7 @@ export function useSqlExecution(deps: {
pendingSourceOffset.value = undefined;
pendingOpenInNewResultTab.value = false;
pendingSqlParameterEditorViewportRequestId.value = undefined;
pendingSqlParameterTabId.value = undefined;
pendingSqlParameterContinuation = undefined;
cancelEditorViewportRequest(editorViewportRequestId);
});
@@ -637,6 +777,7 @@ export function useSqlExecution(deps: {
pendingDangerKind.value = "sql";
pendingOpenInNewResultTab.value = false;
pendingDangerEditorViewportRequestId.value = undefined;
pendingDangerTabId.value = undefined;
suppressDangerConfirm.value = false;
cancelEditorViewportRequest(editorViewportRequestId);
});
@@ -650,6 +791,7 @@ export function useSqlExecution(deps: {
tryExecuteInNewResultTab,
doExecute,
cancelActiveExecution,
requestDangerConfirmation,
tryExplain,
onDangerConfirm,
showSqlParameterDialog,
-245
View File
@@ -1,245 +0,0 @@
import { reactive, readonly } from "vue";
export type TabDropPosition = "before" | "after";
interface TabDragState {
active: boolean;
draggedId: string | null;
targetId: string | null;
dropPosition: TabDropPosition | null;
suppressClick: boolean;
startX: number;
startY: number;
}
// Many touchscreen digitizers report to the OS/webview as a plain HID mouse
// (no distinguishable pointerType), with tap contact-point drift well beyond
// what real mouse/trackpad click jitter produces. 24px absorbs that jitter
// while still requiring a clearly deliberate drag to reorder a tab.
export const TAB_DRAG_HORIZONTAL_THRESHOLD = 24;
const state = reactive<TabDragState>({
active: false,
draggedId: null,
targetId: null,
dropPosition: null,
suppressClick: false,
startX: 0,
startY: 0,
});
let pending: {
id: string;
x: number;
y: number;
pointerId: number;
sourceEl: HTMLElement | null;
} | null = null;
let onDropCallback: ((draggedId: string, targetId: string, position: TabDropPosition) => boolean) | null = null;
let onDetachCallback: ((draggedId: string, position: { x: number; y: number }) => boolean) | null = null;
let detachBoundsProvider: (() => DOMRect | undefined) | null = null;
let dragAxis: () => "horizontal" | "vertical" = () => "horizontal";
let ghostEl: HTMLElement | null = null;
function createGhost(sourceEl: HTMLElement, x: number, y: number) {
const ghost = document.createElement("div");
const textNode = sourceEl.querySelector(".truncate");
ghost.textContent = textNode?.textContent || "";
ghost.style.cssText = `
position: fixed;
pointer-events: none;
z-index: 9999;
opacity: 0.9;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
border-radius: var(--dbx-radius-fixed-6);
background: var(--background, #fff);
border: 1px solid var(--border, #e5e7eb);
max-width: 200px;
height: 28px;
padding: 0 12px;
font-size: 12px;
line-height: 28px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
left: ${x + 12}px;
top: ${y - 14}px;
`;
document.body.appendChild(ghost);
return ghost;
}
function moveGhost(x: number, y: number) {
if (!ghostEl) return;
ghostEl.style.left = `${x + 8}px`;
ghostEl.style.top = `${y - 14}px`;
}
function removeGhost() {
if (ghostEl) {
ghostEl.remove();
ghostEl = null;
}
}
function activateDrag(x: number, y: number) {
if (!pending) return;
activePointerId = pending.pointerId;
state.active = true;
state.draggedId = pending.id;
state.startX = pending.x;
state.startY = pending.y;
if (pending.sourceEl) {
ghostEl = createGhost(pending.sourceEl, x, y);
}
pending = null;
document.body.style.cursor = "grabbing";
document.body.style.userSelect = "none";
}
function onPointerMove(event: PointerEvent) {
if (!pending && !state.active) return;
const trackedPointerId = pending?.pointerId ?? activePointerId;
if (trackedPointerId !== null && trackedPointerId !== undefined && event.pointerId !== trackedPointerId) return;
// macOS reports a trackpad tap as a mouse pointer with button=0, but
// buttons=0. It is a click gesture, not a held primary-button drag.
if ((event.buttons & 1) !== 1) {
reset();
return;
}
if (pending && !state.active) {
const delta = dragAxis() === "vertical" ? event.clientY - pending.y : event.clientX - pending.x;
if (Math.abs(delta) < TAB_DRAG_HORIZONTAL_THRESHOLD) return;
activateDrag(event.clientX, event.clientY);
}
if (state.active) {
moveGhost(event.clientX, event.clientY);
}
}
function onMouseUp(event: PointerEvent) {
const trackedPointerId = pending?.pointerId ?? activePointerId;
if (trackedPointerId !== null && trackedPointerId !== undefined && event.pointerId !== trackedPointerId) return;
state.suppressClick = false;
if (state.active && state.draggedId && onDetachCallback && isOutsideDetachBounds(event.clientX, event.clientY)) {
const scale = typeof window !== "undefined" && Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0 ? window.devicePixelRatio : 1;
state.suppressClick = onDetachCallback(state.draggedId, { x: event.screenX * scale, y: event.screenY * scale });
} else if (state.active && state.draggedId && state.targetId && state.dropPosition && onDropCallback) {
state.suppressClick = onDropCallback(state.draggedId, state.targetId, state.dropPosition);
}
reset();
}
function onPointerCancel(event?: Event) {
const trackedPointerId = pending?.pointerId ?? activePointerId;
if (event && "pointerId" in event && trackedPointerId !== null && trackedPointerId !== undefined && (event as PointerEvent).pointerId !== trackedPointerId) return;
state.suppressClick = false;
reset();
}
function isOutsideDetachBounds(x: number, y: number): boolean {
const bounds = detachBoundsProvider?.();
return !!bounds && (x < bounds.left || x > bounds.right || y < bounds.top || y > bounds.bottom);
}
function reset() {
state.active = false;
state.draggedId = null;
state.targetId = null;
state.dropPosition = null;
state.startX = 0;
state.startY = 0;
activePointerId = null;
pending = null;
removeGhost();
document.body.style.cursor = "";
document.body.style.userSelect = "";
}
let listenersAttached = false;
let activePointerId: number | null = null;
function ensureListeners() {
if (listenersAttached) return;
document.addEventListener("pointermove", onPointerMove, true);
document.addEventListener("pointerup", onMouseUp, true);
document.addEventListener("pointercancel", onPointerCancel, true);
window.addEventListener("blur", onPointerCancel, true);
listenersAttached = true;
}
export function useTabDrag(onDrop: (draggedId: string, targetId: string, position: TabDropPosition) => boolean, onDetach?: (draggedId: string, position: { x: number; y: number }) => boolean, axis: () => "horizontal" | "vertical" = () => "horizontal") {
ensureListeners();
onDropCallback = onDrop;
onDetachCallback = onDetach ?? null;
dragAxis = axis;
function setDetachBoundsProvider(provider: (() => DOMRect | undefined) | null) {
detachBoundsProvider = provider;
}
function startDrag(event: PointerEvent, tabId: string) {
if (event.button !== 0) return;
// Consume click suppression on the next primary-button gesture even when
// macOS reports a trackpad tap with buttons=0 and no drag can be armed.
state.suppressClick = false;
if ((event.buttons & 1) !== 1) return;
// Touch input has no reliable equivalent of mouse jitter: a real finger's
// contact point commonly drifts well past TAB_DRAG_HORIZONTAL_THRESHOLD
// during an ordinary tap, which would misread the tap as a drag. Skip
// arming the drag for touch entirely and let the tab strip's native
// horizontal scroll handle the gesture instead.
if (event.pointerType === "touch") return;
const target = event.target as HTMLElement;
if (target.closest("button, input, [data-tab-title-input]")) return;
const el = (event.currentTarget as HTMLElement) || null;
pending = {
id: tabId,
x: event.clientX,
y: event.clientY,
pointerId: event.pointerId,
sourceEl: el,
};
}
function updateTarget(event: MouseEvent, tabId: string) {
if (!state.active || tabId === state.draggedId) {
if (state.targetId === tabId) {
state.targetId = null;
state.dropPosition = null;
}
return;
}
state.targetId = tabId;
const el = event.currentTarget as HTMLElement;
const rect = el.getBoundingClientRect();
const vertical = axis() === "vertical";
const offset = vertical ? event.clientY - rect.top : event.clientX - rect.left;
const size = vertical ? rect.height : rect.width;
state.dropPosition = offset < size / 2 ? "before" : "after";
}
function clearTarget(tabId: string) {
if (state.targetId === tabId) {
state.targetId = null;
state.dropPosition = null;
}
}
return {
state: readonly(state),
startDrag,
updateTarget,
clearTarget,
setDetachBoundsProvider,
};
}
@@ -0,0 +1,87 @@
import { nextTick, onUnmounted, ref, watch, type Ref, type WatchSource } from "vue";
import { resolveNextEditorToolbarTier, type EditorToolbarTier } from "@/lib/tabs/editorToolbarLayout";
/**
* Measured progressive condensation for single-row toolbars — the same
* mechanism as the SQL editor toolbar (resolveNextEditorToolbarTier): while
* the row's content overflows its available width the tier steps up (moving
* controls into an overflow menu); it only steps back down when there is real
* slack AND the pane has grown meaningfully since the last condensation, so a
* static narrow layout can never oscillate.
*
* Measurement contract: the toolbar row must be `overflow-hidden` with
* non-shrinking (shrink-0) or floored (min-w-*) children, so scrollWidth
* reliably exceeds clientWidth once nothing can shrink any further. Pure
* `truncate` children without a floor collapse to zero width before the row
* ever reports overflow, which would silently disable condensation.
*
* @param rootRef the toolbar row element
* @param revalidateSources reactive sources whose change alters the row's
* visible control set (active tab, feature flags, conditional controls);
* each change re-measures, because hiding/restoring controls changes the
* content extent without resizing the toolbar box.
*/
export function useToolbarOverflow(rootRef: Ref<HTMLElement | null>, revalidateSources: WatchSource[] = []) {
const tier = ref<EditorToolbarTier>(0);
// Available width when the current tier was condensed into; anchors the
// step-down hysteresis so a static narrow layout cannot oscillate.
const condensedAtWidth = ref(0);
let resizeObserver: ResizeObserver | undefined;
function measure() {
const element = rootRef.value;
if (!element) {
return;
}
const next = resolveNextEditorToolbarTier({
tier: tier.value,
availableWidth: element.clientWidth,
contentWidth: element.scrollWidth,
condensedAtWidth: condensedAtWidth.value,
});
if (next !== tier.value) {
if (next > tier.value) {
condensedAtWidth.value = element.clientWidth;
}
tier.value = next;
}
}
// Hiding or restoring controls changes the row content without resizing the
// toolbar box, so every tier change re-measures until the row settles.
watch(tier, () => {
void nextTick(measure);
});
if (revalidateSources.length > 0) {
watch(revalidateSources, () => {
void nextTick(measure);
});
}
watch(
rootRef,
(element) => {
resizeObserver?.disconnect();
resizeObserver = undefined;
// A freshly attached row (mode switch, tab remount) starts from the full
// layout and converges from real measurements instead of inheriting a
// stale tier from whatever was previously measured.
tier.value = 0;
condensedAtWidth.value = 0;
if (element && typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(measure);
resizeObserver.observe(element);
}
void nextTick(measure);
},
{ flush: "post" },
);
onUnmounted(() => {
resizeObserver?.disconnect();
resizeObserver = undefined;
});
return { tier, measure };
}