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
@@ -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 }));
|
||||
});
|
||||
|
||||
+13
-13
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user