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
@@ -5,6 +5,7 @@ import { createQueryEditorExecutionViewportOwnership, isQueryEditorPositionVisib
|
||||
const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8");
|
||||
const contentAreaSource = readFileSync(new URL("../../../components/layout/ContentArea.vue", import.meta.url), "utf8");
|
||||
const editorToolbarSource = readFileSync(new URL("../../../components/layout/EditorToolbar.vue", import.meta.url), "utf8");
|
||||
const editorGroupSource = readFileSync(new URL("../../../components/layout/EditorGroup.vue", import.meta.url), "utf8");
|
||||
const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8");
|
||||
const sqlExecutionSource = readFileSync(new URL("../../../composables/useSqlExecution.ts", import.meta.url), "utf8");
|
||||
const queryStoreSource = readFileSync(new URL("../../../stores/queryStore.ts", import.meta.url), "utf8");
|
||||
@@ -56,14 +57,21 @@ describe("QueryEditor execution routing", () => {
|
||||
expect(queryEditorSource).toContain("return sqlExecutionSnapshotFromView(currentView);");
|
||||
expect(contentAreaSource).toContain("function captureQueryEditorExecutionSnapshot()");
|
||||
expect(contentAreaSource).toContain("queryEditorRef.value?.captureExecutionSnapshot();");
|
||||
expect(appSource).toContain("const pendingToolbarExecutionSnapshot = ref<SqlExecutionSnapshot>();");
|
||||
expect(appSource).toContain("pendingToolbarExecutionSnapshot.value = contentAreaRef.value?.captureQueryEditorExecutionSnapshot?.();");
|
||||
expect(appSource).toContain("const pendingToolbarExecutionSnapshot = ref<SqlExecutionSnapshot & { tabId?: string }>();");
|
||||
expect(appSource).toContain("pendingToolbarExecutionSnapshot.value = snapshot ? { ...snapshot, tabId: activeTab.value?.id } : undefined;");
|
||||
expect(appSource).toContain('if (source === "pointer")');
|
||||
expect(appSource).toContain("pendingToolbarExecutionSnapshot.value = undefined;");
|
||||
expect(appSource).toContain("void tryExecute(snapshot);");
|
||||
expect(appSource).toContain('@execute-pointer-down="captureActiveEditorExecutionSnapshot()"');
|
||||
expect(appSource).toContain('@execute="requestActiveEditorExecute($event)"');
|
||||
expect(appSource).toContain("void tryExecute(snapshot, { tabId: snapshot.tabId ?? activeTab.value?.id });");
|
||||
expect(appSource).toContain('@execute="(tabId: string, override?: SqlExecutionOverride) => tryExecute(override, { tabId })"');
|
||||
// Per-group toolbars call back into App-owned orchestration via injection.
|
||||
expect(appSource).toContain("provide(EDITOR_TOOLBAR_ACTIONS, {");
|
||||
expect(appSource).toContain("captureExecutionSnapshot: captureActiveEditorExecutionSnapshot,");
|
||||
expect(appSource).toContain("toolbarExecute: requestActiveEditorExecute,");
|
||||
expect(editorGroupSource).toContain('@execute-pointer-down="toolbar.captureExecutionSnapshot()"');
|
||||
expect(editorGroupSource).toContain('@toolbar-execute="toolbar.toolbarExecute($event)"');
|
||||
expect(editorToolbarSource).toContain("function onExecutePointerDown(event: MouseEvent)");
|
||||
expect(editorToolbarSource).toContain('emit("toolbarExecute", event.detail > 0 ? "pointer" : "keyboard")');
|
||||
expect(editorToolbarSource).not.toContain('emit("execute", event.detail > 0 ? "pointer" : "keyboard")');
|
||||
});
|
||||
|
||||
it("uses the opt-in blank-line fallback and otherwise reports the missing cursor statement", () => {
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("QueryEditor auto focus wiring", () => {
|
||||
});
|
||||
|
||||
it("enables auto focus for query tabs", () => {
|
||||
expect(contentAreaSource).toMatch(/<QueryEditor[\s\S]*?\sauto-focus\s[\s\S]*?:model-value="activeTab\.sql"/);
|
||||
expect(contentAreaSource).toMatch(/<QueryEditor[\s\S]*?:\s*auto-focus="autoFocus !== false"\s[\s\S]*?:model-value="activeTab\.sql"/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8");
|
||||
const useSqlExecutionSource = readFileSync(new URL("../../../composables/useSqlExecution.ts", import.meta.url), "utf8");
|
||||
|
||||
describe("toolbar explain routing", () => {
|
||||
it("passes the tab id as the execution context, not as SQL", () => {
|
||||
// Regression: the injected action once called tryExplain(tabId), whose
|
||||
// first parameter is sqlOverride — the id silently typed as SQL and the
|
||||
// EXPLAIN button sent the tab UUID to the backend.
|
||||
expect(appSource).toContain("explain: (tabId: string) => tryExplain(undefined, { tabId }),");
|
||||
expect(appSource).not.toContain("explain: (tabId: string) => tryExplain(tabId),");
|
||||
});
|
||||
|
||||
it("keeps tryExplain's first parameter an SQL override", () => {
|
||||
expect(useSqlExecutionSource).toContain("async function tryExplain(sqlOverride?: SqlExecutionOverride, options: SqlExecutionOptions = {})");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EDITOR_TOOLBAR_MAX_TIER, EDITOR_TOOLBAR_STEP_DOWN_MIN_GROWTH_PX, EDITOR_TOOLBAR_STEP_DOWN_SLACK_PX, resolveNextEditorToolbarTier } from "@/lib/tabs/editorToolbarLayout";
|
||||
|
||||
describe("editor toolbar measured condensation", () => {
|
||||
it("steps up one tier while the content overflows", () => {
|
||||
expect(resolveNextEditorToolbarTier({ tier: 0, availableWidth: 400, contentWidth: 700 })).toBe(1);
|
||||
expect(resolveNextEditorToolbarTier({ tier: 1, availableWidth: 400, contentWidth: 450 })).toBe(2);
|
||||
expect(resolveNextEditorToolbarTier({ tier: 2, availableWidth: 400, contentWidth: 420 })).toBe(3);
|
||||
});
|
||||
|
||||
it("holds at the maximum tier when even the condensed row overflows", () => {
|
||||
expect(resolveNextEditorToolbarTier({ tier: EDITOR_TOOLBAR_MAX_TIER, availableWidth: 200, contentWidth: 400 })).toBe(EDITOR_TOOLBAR_MAX_TIER);
|
||||
});
|
||||
|
||||
it("holds the current tier while the row fits without real slack", () => {
|
||||
expect(resolveNextEditorToolbarTier({ tier: 1, availableWidth: 500, contentWidth: 480 })).toBe(1);
|
||||
expect(resolveNextEditorToolbarTier({ tier: 1, availableWidth: 500, contentWidth: 500 - EDITOR_TOOLBAR_STEP_DOWN_SLACK_PX + 1 })).toBe(1);
|
||||
});
|
||||
|
||||
it("steps down only with slack and real pane growth since the last condensation", () => {
|
||||
const grown = 400 + EDITOR_TOOLBAR_STEP_DOWN_MIN_GROWTH_PX + 10;
|
||||
expect(resolveNextEditorToolbarTier({ tier: 2, availableWidth: grown, contentWidth: grown - EDITOR_TOOLBAR_STEP_DOWN_SLACK_PX - 10, condensedAtWidth: 400 })).toBe(1);
|
||||
});
|
||||
|
||||
it("never oscillates: slack alone without pane growth keeps the condensed tier", () => {
|
||||
// The row fits with plenty of slack, but the pane barely grew since the
|
||||
// toolbar condensed at this width — stepping down would overflow again.
|
||||
expect(resolveNextEditorToolbarTier({ tier: 2, availableWidth: 410, contentWidth: 350, condensedAtWidth: 400 })).toBe(2);
|
||||
expect(resolveNextEditorToolbarTier({ tier: 2, availableWidth: 410, contentWidth: 410 - EDITOR_TOOLBAR_STEP_DOWN_SLACK_PX - 1, condensedAtWidth: 400 })).toBe(2);
|
||||
});
|
||||
|
||||
it("allows stepping down immediately when the toolbar was never condensed", () => {
|
||||
expect(resolveNextEditorToolbarTier({ tier: 1, availableWidth: 900, contentWidth: 500 })).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the current tier for unmeasured or invalid input", () => {
|
||||
expect(resolveNextEditorToolbarTier({ tier: 1, availableWidth: 0, contentWidth: 0 })).toBe(1);
|
||||
expect(resolveNextEditorToolbarTier({ tier: 2, availableWidth: Number.NaN, contentWidth: 100 })).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseTabDragPayload, serializeTabDragPayload, TAB_DRAG_MIME } from "@/lib/tabs/tabDrag";
|
||||
|
||||
describe("tab drag payload", () => {
|
||||
it("serializes and parses a valid payload", () => {
|
||||
const raw = serializeTabDragPayload({ tabId: "tab-1", sourceGroupId: "group-1" });
|
||||
expect(raw).toContain(TAB_DRAG_MIME);
|
||||
expect(parseTabDragPayload(raw)).toEqual({ mime: TAB_DRAG_MIME, tabId: "tab-1", sourceGroupId: "group-1" });
|
||||
});
|
||||
|
||||
it("rejects malformed payloads", () => {
|
||||
expect(parseTabDragPayload("not-json")).toBeNull();
|
||||
expect(parseTabDragPayload(JSON.stringify({ tabId: "tab-1" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects payloads carrying a foreign MIME discriminator", () => {
|
||||
expect(parseTabDragPayload(JSON.stringify({ mime: "application/json", tabId: "tab-1", sourceGroupId: "group-1" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("exposes the custom MIME type", () => {
|
||||
expect(TAB_DRAG_MIME).toBe("application/x-dbx-tab");
|
||||
});
|
||||
});
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCloseAllTabMenuItem, createCloseLeftTabMenuItem, createCloseOtherTabMenuItem, createCloseRightTabMenuItem, createCloseTabMenuItem, createLocateTabMenuItem, createPinTabMenuItem, createRenameDuplicateTabItems } from "@/lib/tabs/tabMenu";
|
||||
|
||||
const t = (key: string) => key;
|
||||
|
||||
describe("shared tab menu helpers", () => {
|
||||
it("builds rename and duplicate items only when renaming is allowed", () => {
|
||||
const items = createRenameDuplicateTabItems({
|
||||
tab: { id: "t1" } as never,
|
||||
t,
|
||||
canRename: true,
|
||||
onRename: () => undefined,
|
||||
onDuplicate: () => undefined,
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]?.label).toBe("contextMenu.renameTab");
|
||||
expect(items[1]?.label).toBe("contextMenu.duplicateTab");
|
||||
expect(items[1]?.visible).toBe(true);
|
||||
|
||||
const queryOnly = createRenameDuplicateTabItems({
|
||||
tab: { id: "t2" } as never,
|
||||
t,
|
||||
canRename: false,
|
||||
onRename: () => undefined,
|
||||
onDuplicate: () => undefined,
|
||||
});
|
||||
expect(queryOnly).toHaveLength(1);
|
||||
expect(queryOnly[0]?.visible).toBe(false);
|
||||
});
|
||||
|
||||
it("builds locate and pin menu items with the expected actions", () => {
|
||||
let located = false;
|
||||
let toggled = false;
|
||||
|
||||
const locate = createLocateTabMenuItem({
|
||||
t,
|
||||
visible: true,
|
||||
onLocate: () => {
|
||||
located = true;
|
||||
},
|
||||
});
|
||||
locate.action?.();
|
||||
expect(located).toBe(true);
|
||||
expect(locate.label).toBe("sidebar.locateActiveTab");
|
||||
|
||||
const hiddenLocate = createLocateTabMenuItem({
|
||||
t,
|
||||
visible: false,
|
||||
onLocate: () => undefined,
|
||||
});
|
||||
expect(hiddenLocate.visible).toBe(false);
|
||||
|
||||
const pin = createPinTabMenuItem({
|
||||
label: "Pin",
|
||||
iconClass: "fill-current",
|
||||
onToggle: () => {
|
||||
toggled = true;
|
||||
},
|
||||
});
|
||||
pin.action?.();
|
||||
expect(toggled).toBe(true);
|
||||
expect(pin.iconClass).toBe("fill-current");
|
||||
|
||||
const pinWithoutIconClass = createPinTabMenuItem({
|
||||
label: "Pin",
|
||||
onToggle: () => undefined,
|
||||
});
|
||||
expect(pinWithoutIconClass.iconClass).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds close menu items with labels and actions", () => {
|
||||
let closed = false;
|
||||
const close = createCloseTabMenuItem({
|
||||
label: "Close",
|
||||
onClose: () => {
|
||||
closed = true;
|
||||
},
|
||||
});
|
||||
close.action?.();
|
||||
expect(closed).toBe(true);
|
||||
|
||||
const closeOther = createCloseOtherTabMenuItem({
|
||||
label: "Close Other",
|
||||
disabled: true,
|
||||
onClose: () => undefined,
|
||||
});
|
||||
expect(closeOther.disabled).toBe(true);
|
||||
|
||||
const closeOtherWithoutDisabled = createCloseOtherTabMenuItem({
|
||||
label: "Close Other",
|
||||
onClose: () => undefined,
|
||||
});
|
||||
expect("disabled" in closeOtherWithoutDisabled).toBe(false);
|
||||
|
||||
const closeRight = createCloseRightTabMenuItem({
|
||||
label: "Close Right",
|
||||
disabled: false,
|
||||
onClose: () => undefined,
|
||||
});
|
||||
expect(closeRight.disabled).toBe(false);
|
||||
|
||||
const closeRightWithoutDisabled = createCloseRightTabMenuItem({
|
||||
label: "Close Right",
|
||||
onClose: () => undefined,
|
||||
});
|
||||
expect("disabled" in closeRightWithoutDisabled).toBe(false);
|
||||
|
||||
const closeLeft = createCloseLeftTabMenuItem({
|
||||
label: "Close Left",
|
||||
disabled: true,
|
||||
onClose: () => undefined,
|
||||
});
|
||||
expect(closeLeft.disabled).toBe(true);
|
||||
|
||||
const closeLeftWithoutDisabled = createCloseLeftTabMenuItem({
|
||||
label: "Close Left",
|
||||
onClose: () => undefined,
|
||||
});
|
||||
expect("disabled" in closeLeftWithoutDisabled).toBe(false);
|
||||
|
||||
const closeAll = createCloseAllTabMenuItem({
|
||||
label: "Close All",
|
||||
variant: "destructive",
|
||||
onClose: () => undefined,
|
||||
});
|
||||
expect(closeAll.variant).toBe("destructive");
|
||||
|
||||
const closeAllDefault = createCloseAllTabMenuItem({
|
||||
label: "Close All",
|
||||
onClose: () => undefined,
|
||||
});
|
||||
expect(closeAllDefault.variant).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -12,9 +12,13 @@ import {
|
||||
resultGridInstanceKey,
|
||||
resultSourceRange,
|
||||
statementExecutionMarkers,
|
||||
tabColorStyle,
|
||||
tabDatabaseIconType,
|
||||
tabDisplayTitle,
|
||||
tabIconClass,
|
||||
tabTooltipLines,
|
||||
tabularResultItems,
|
||||
dirtyTabTitleStyle,
|
||||
} from "@/lib/tabs/tabPresentation";
|
||||
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
|
||||
import type { ConnectionConfig, QueryTab } from "@/types/database";
|
||||
@@ -396,3 +400,38 @@ describe("statement execution markers", () => {
|
||||
expect(statementExecutionMarkers(`-- edited\n${executedSql}`, [{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, statement_index: 0, sourceStatement: "SELECT 1" }], "mysql", "stale-editor-fingerprint", executedSql)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared tab presentation helpers", () => {
|
||||
it("classifies tab icon colors without MQ special-casing", () => {
|
||||
expect(tabIconClass(queryTab({ mode: "data" }))).toContain("text-emerald-600");
|
||||
expect(tabIconClass(queryTab({ mode: "mq" }))).toBe("");
|
||||
expect(tabIconClass(queryTab({ externalSqlFileMissing: true }))).toContain("text-amber-600");
|
||||
});
|
||||
|
||||
it("builds active/inactive color styles for classic and non-classic layouts", () => {
|
||||
const activeClassic = tabColorStyle(queryTab({}), true, true);
|
||||
expect(activeClassic?.boxShadow).toContain("var(--ring)");
|
||||
const inactiveModern = tabColorStyle(queryTab({}), false, false);
|
||||
expect(inactiveModern?.borderColor).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves MQ driver icons from the connection store", () => {
|
||||
const connectionStore = useConnectionStore();
|
||||
connectionStore.connections = [
|
||||
{
|
||||
id: "mq-1",
|
||||
name: "MQ",
|
||||
db_type: "mq",
|
||||
driver_profile: "kafka",
|
||||
color: "",
|
||||
} as ConnectionConfig,
|
||||
];
|
||||
expect(tabDatabaseIconType(queryTab({ connectionId: "mq-1", mode: "mq" }))).toBe("kafka");
|
||||
});
|
||||
|
||||
it("returns a dirty-title style only when the tab is dirty", () => {
|
||||
expect(dirtyTabTitleStyle(false)).toBeUndefined();
|
||||
expect(dirtyTabTitleStyle(true)?.fontStyle).toBe("italic");
|
||||
expect(dirtyTabTitleStyle(true)?.fontWeight).toBe(700);
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,7 @@ describe("AI Redis console routing", () => {
|
||||
const handler = appSource.match(/function onAiExecuteSql\(sql: string\) \{[\s\S]*?\n\}/)?.[0] ?? "";
|
||||
expect(handler).toContain("buildDeduplicatedAppendedEditorSql(currentSql, sql)");
|
||||
expect(handler).toContain("if (appendedSql !== currentSql) queryStore.updateSql(tabId, appendedSql);");
|
||||
expect(handler).toContain("runAiGeneratedSql(sql);");
|
||||
expect(handler).toContain("runAiGeneratedSql(sql, tabId);");
|
||||
expect(handler).not.toContain("buildAppendedEditorSql(");
|
||||
});
|
||||
|
||||
|
||||
@@ -66,6 +66,23 @@ export interface RestoredOpenTabs {
|
||||
activeTabId: string | null;
|
||||
}
|
||||
|
||||
/** Group membership persisted with the open-tabs payload. */
|
||||
export interface PersistedEditorGroup {
|
||||
id: string;
|
||||
tabIds: string[];
|
||||
activeTabId: string | null;
|
||||
}
|
||||
|
||||
/** Shared open-tabs transport payload — the single definition both backend adapters persist. */
|
||||
export interface OpenTabsStatePayload {
|
||||
tabs: unknown[];
|
||||
activeTabId: string | null;
|
||||
groups?: PersistedEditorGroup[];
|
||||
focusedGroupId?: string;
|
||||
orientation?: "vertical" | "horizontal";
|
||||
sizes?: number[];
|
||||
}
|
||||
|
||||
export type OpenTabsRestoreFilter = "all" | "pinned";
|
||||
export interface OpenTabsRestoreOptions {
|
||||
queryOnly?: boolean;
|
||||
|
||||
@@ -1870,10 +1870,8 @@ export async function saveMaxRetries(maxRetries: number): Promise<void> {
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
}
|
||||
|
||||
export interface OpenTabsStatePayload {
|
||||
tabs: unknown[];
|
||||
activeTabId: string | null;
|
||||
}
|
||||
export type { OpenTabsStatePayload, PersistedEditorGroup } from "@/lib/app/openTabsPersistence";
|
||||
import type { OpenTabsStatePayload } from "@/lib/app/openTabsPersistence";
|
||||
|
||||
export async function loadEditorSettings(): Promise<unknown | null> {
|
||||
return loadBrowserAppState("editor_settings");
|
||||
@@ -1891,6 +1889,10 @@ export async function loadOpenTabsState(): Promise<OpenTabsStatePayload | null>
|
||||
? {
|
||||
tabs: payload.tabs,
|
||||
activeTabId: typeof payload.activeTabId === "string" ? payload.activeTabId : null,
|
||||
...(payload.groups !== undefined ? { groups: payload.groups } : {}),
|
||||
...(typeof payload.focusedGroupId === "string" ? { focusedGroupId: payload.focusedGroupId } : {}),
|
||||
...(payload.orientation === "vertical" || payload.orientation === "horizontal" ? { orientation: payload.orientation } : {}),
|
||||
...(Array.isArray(payload.sizes) ? { sizes: payload.sizes } : {}),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -720,10 +720,8 @@ export async function saveMaxRetries(maxRetries: number): Promise<void> {
|
||||
return invoke("save_max_retries", { maxRetries });
|
||||
}
|
||||
|
||||
export interface OpenTabsStatePayload {
|
||||
tabs: unknown[];
|
||||
activeTabId: string | null;
|
||||
}
|
||||
export type { OpenTabsStatePayload, PersistedEditorGroup } from "@/lib/app/openTabsPersistence";
|
||||
import type { OpenTabsStatePayload } from "@/lib/app/openTabsPersistence";
|
||||
|
||||
export async function loadEditorSettings(): Promise<unknown | null> {
|
||||
return invoke("load_editor_settings");
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ContentAreaSurfaceEmits } from "@/components/layout/querySurfaces";
|
||||
|
||||
/**
|
||||
* Single source of truth for the events every content surface forwards
|
||||
* upward. Each layer (QueryEditorSurface / QueryResultSurface / EditorGroup /
|
||||
* SqlEditorWorkspace) derives its v-bind handler object from this list, so the
|
||||
* four forwarding layers cannot drift apart when the contract changes.
|
||||
*/
|
||||
export const contentSurfaceEventNames = [
|
||||
"update:activeOutputView",
|
||||
"fixWithAi",
|
||||
"sendSelectionToAi",
|
||||
"previewChangesAvailable",
|
||||
"execute",
|
||||
"executeInNewResultTab",
|
||||
"saveSql",
|
||||
"cancel",
|
||||
"explain",
|
||||
"editorUpdate",
|
||||
"editorSelectionChange",
|
||||
"editorCursorChange",
|
||||
"editorViewportChange",
|
||||
"editorSelectionStateChange",
|
||||
"formatError",
|
||||
"reload",
|
||||
"paginate",
|
||||
"sort",
|
||||
"executeSql",
|
||||
"clickTable",
|
||||
"viewTableData",
|
||||
"viewTableDdl",
|
||||
"editTableStructure",
|
||||
"openObjectSource",
|
||||
"openObjectTable",
|
||||
"objectSchemaChange",
|
||||
"objectBrowserViewportChange",
|
||||
"structureEditorSaved",
|
||||
"structureEditorClose",
|
||||
"previewStatement",
|
||||
"focusStatement",
|
||||
"openSettings",
|
||||
"openConnectionSettings",
|
||||
"toggleZenMode",
|
||||
] as const satisfies Array<keyof ContentAreaSurfaceEmits>;
|
||||
|
||||
export type ContentSurfaceEventName = (typeof contentSurfaceEventNames)[number];
|
||||
|
||||
export type ContentSurfaceEventForwarders = {
|
||||
[K in ContentSurfaceEventName as `on${Capitalize<K & string>}`]: (...args: ContentAreaSurfaceEmits[K]) => void;
|
||||
};
|
||||
|
||||
type ContentSurfaceEmit = <K extends ContentSurfaceEventName>(event: K, ...args: ContentAreaSurfaceEmits[K]) => void;
|
||||
|
||||
/**
|
||||
* Builds the v-bind event object that re-emits every shared content event.
|
||||
* Emitters stay typed through ContentAreaSurfaceEmits, so adding a new event
|
||||
* to the contract only requires extending the event list once.
|
||||
*/
|
||||
export function createContentSurfaceEventForwarders(emit: ContentSurfaceEmit): ContentSurfaceEventForwarders {
|
||||
const forwarders = {} as Record<string, (...args: unknown[]) => void>;
|
||||
for (const event of contentSurfaceEventNames) {
|
||||
const handlerName = `on${event.charAt(0).toUpperCase()}${event.slice(1)}`;
|
||||
forwarders[handlerName] = (...args: unknown[]) => {
|
||||
(emit as (event: ContentSurfaceEventName, ...args: unknown[]) => void)(event, ...args);
|
||||
};
|
||||
}
|
||||
return forwarders as ContentSurfaceEventForwarders;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Progressive condensation for the per-group SQL editor toolbar.
|
||||
*
|
||||
* The tier is NOT derived from fixed width breakpoints — the control set
|
||||
* changes per database type, transaction state, and feature flags, so any
|
||||
* static guess would over- or under-condense. Instead the toolbar measures its
|
||||
* own row: while the content overflows the available width it steps up one
|
||||
* tier (moving controls into the 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.
|
||||
*
|
||||
* - Tier 0: full layout.
|
||||
* - Tier 1: secondary edit/entry actions (compress, keyword case, semantic
|
||||
* diagnostics, open SQL, import archive, paste-in-condition, multi-execute)
|
||||
* move into the overflow menu.
|
||||
* - Tier 2: format and the database helper buttons (clear / set-default) join
|
||||
* the overflow menu.
|
||||
* - Tier 3: the explain-analyze toggle and the schema/catalog selectors leave
|
||||
* the row; the row keeps execute, explain, save, transaction controls, the
|
||||
* connection and database selectors (hard-truncated), and the overflow menu.
|
||||
*/
|
||||
export type EditorToolbarTier = 0 | 1 | 2 | 3;
|
||||
|
||||
export const EDITOR_TOOLBAR_MAX_TIER = 3;
|
||||
|
||||
/** Slack (px) required before giving a tier back — absorbs rendering jitter. */
|
||||
export const EDITOR_TOOLBAR_STEP_DOWN_SLACK_PX = 48;
|
||||
/** How much wider the pane must grow (px) vs the last condensation before stepping down. */
|
||||
export const EDITOR_TOOLBAR_STEP_DOWN_MIN_GROWTH_PX = 64;
|
||||
|
||||
export interface EditorToolbarTierInput {
|
||||
tier: EditorToolbarTier;
|
||||
/** Measured inner width of the toolbar row. */
|
||||
availableWidth: number;
|
||||
/** Measured extent of the row's content (scrollWidth), including overflow. */
|
||||
contentWidth: number;
|
||||
/** Toolbar width when the current tier was condensed into; anchors step-down hysteresis. */
|
||||
condensedAtWidth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the next toolbar tier from real measurements. Monotonic per
|
||||
* measurement — repeated calls after a tier change converge instead of
|
||||
* oscillating, because stepping down additionally requires the pane to have
|
||||
* grown by EDITOR_TOOLBAR_STEP_DOWN_MIN_GROWTH_PX since the last step up.
|
||||
*/
|
||||
export function resolveNextEditorToolbarTier(input: EditorToolbarTierInput): EditorToolbarTier {
|
||||
const { tier, availableWidth, contentWidth, condensedAtWidth = 0 } = input;
|
||||
if (!Number.isFinite(availableWidth) || !Number.isFinite(contentWidth) || availableWidth <= 0 || contentWidth <= 0) {
|
||||
// Unmeasured (first paint, hosts without ResizeObserver) keeps the current tier.
|
||||
return tier;
|
||||
}
|
||||
if (contentWidth > availableWidth && tier < EDITOR_TOOLBAR_MAX_TIER) {
|
||||
return (tier + 1) as EditorToolbarTier;
|
||||
}
|
||||
if (tier > 0 && availableWidth - contentWidth >= EDITOR_TOOLBAR_STEP_DOWN_SLACK_PX && availableWidth - condensedAtWidth >= EDITOR_TOOLBAR_STEP_DOWN_MIN_GROWTH_PX) {
|
||||
return (tier - 1) as EditorToolbarTier;
|
||||
}
|
||||
return tier;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export const TAB_DRAG_MIME = "application/x-dbx-tab";
|
||||
|
||||
export interface TabDragPayload {
|
||||
mime: typeof TAB_DRAG_MIME;
|
||||
tabId: string;
|
||||
sourceGroupId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the drag contract under the project's custom MIME type. The MIME
|
||||
* value travels inside the payload as a discriminator, so a drop handler can
|
||||
* reject foreign or stale payloads before touching store state.
|
||||
*/
|
||||
export function serializeTabDragPayload(payload: { tabId: string; sourceGroupId: string }): string {
|
||||
return JSON.stringify({ mime: TAB_DRAG_MIME, tabId: payload.tabId, sourceGroupId: payload.sourceGroupId });
|
||||
}
|
||||
|
||||
export function parseTabDragPayload(raw: string): TabDragPayload | null {
|
||||
try {
|
||||
const value = JSON.parse(raw) as Partial<TabDragPayload>;
|
||||
if (value.mime === TAB_DRAG_MIME && typeof value.tabId === "string" && typeof value.sourceGroupId === "string") {
|
||||
return { mime: TAB_DRAG_MIME, tabId: value.tabId, sourceGroupId: value.sourceGroupId };
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Copy, Crosshair, Pencil, Pin, X } from "@lucide/vue";
|
||||
import type { ContextMenuItem } from "@/components/ui/customContextMenuRegistry";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
|
||||
export function createRenameDuplicateTabItems(options: { tab: QueryTab; t: (key: string) => string; canRename: boolean; onRename: () => void; onDuplicate: () => void }): ContextMenuItem[] {
|
||||
return [
|
||||
...(options.canRename
|
||||
? [
|
||||
{
|
||||
label: options.t("contextMenu.renameTab"),
|
||||
action: options.onRename,
|
||||
icon: Pencil,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: options.t("contextMenu.duplicateTab"),
|
||||
action: options.onDuplicate,
|
||||
icon: Copy,
|
||||
visible: options.canRename,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function createLocateTabMenuItem(options: { t: (key: string) => string; visible: boolean; onLocate: () => void }): ContextMenuItem {
|
||||
return {
|
||||
label: options.t("sidebar.locateActiveTab"),
|
||||
action: options.onLocate,
|
||||
icon: Crosshair,
|
||||
visible: options.visible,
|
||||
};
|
||||
}
|
||||
|
||||
export function createPinTabMenuItem(options: { label: string; iconClass?: string; onToggle: () => void }): ContextMenuItem {
|
||||
return {
|
||||
label: options.label,
|
||||
action: options.onToggle,
|
||||
icon: Pin,
|
||||
...(options.iconClass ? { iconClass: options.iconClass } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createCloseTabMenuItem(options: { label: string; onClose: () => void }): ContextMenuItem {
|
||||
return { label: options.label, action: options.onClose, icon: X };
|
||||
}
|
||||
|
||||
export function createCloseOtherTabMenuItem(options: { label: string; disabled?: boolean; shortcut?: string; onClose: () => void }): ContextMenuItem {
|
||||
return {
|
||||
label: options.label,
|
||||
action: options.onClose,
|
||||
...(options.disabled !== undefined ? { disabled: options.disabled } : {}),
|
||||
...(options.shortcut ? { shortcut: options.shortcut } : {}),
|
||||
icon: X,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCloseRightTabMenuItem(options: { label: string; disabled?: boolean; onClose: () => void }): ContextMenuItem {
|
||||
return {
|
||||
label: options.label,
|
||||
action: options.onClose,
|
||||
...(options.disabled !== undefined ? { disabled: options.disabled } : {}),
|
||||
icon: X,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCloseLeftTabMenuItem(options: { label: string; disabled?: boolean; onClose: () => void }): ContextMenuItem {
|
||||
return {
|
||||
label: options.label,
|
||||
action: options.onClose,
|
||||
...(options.disabled !== undefined ? { disabled: options.disabled } : {}),
|
||||
icon: X,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCloseAllTabMenuItem(options: { label: string; variant?: "destructive"; onClose: () => void }): ContextMenuItem {
|
||||
return {
|
||||
label: options.label,
|
||||
action: options.onClose,
|
||||
...(options.variant ? { variant: options.variant } : {}),
|
||||
icon: X,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { hexToRgba } from "@/lib/common/color";
|
||||
import type { CSSProperties } from "vue";
|
||||
import { findConnectionGroupPath } from "@/lib/sidebar/sidebarLayout";
|
||||
import { splitMongoCommandRanges } from "@/lib/mongo/mongoShellCommand";
|
||||
import { executableStatementRanges, splitSqlStatementRanges, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
|
||||
@@ -464,3 +466,60 @@ export function tabModeLabel(tab: QueryTab, t: Translate): string {
|
||||
if (tab.mode === "dolt-version-control") return t("doltVersionControl.title");
|
||||
return tab.mode;
|
||||
}
|
||||
|
||||
export function tabDatabaseIconType(tab: QueryTab): string {
|
||||
const connectionStore = useConnectionStore();
|
||||
const connection = connectionStore.getConfig(tab.connectionId);
|
||||
if (!connection) return "mq";
|
||||
if (connection.db_type === "mq") {
|
||||
const externalConfig = connection.external_config as { systemKind?: unknown } | undefined;
|
||||
const systemKind = typeof externalConfig?.systemKind === "string" ? externalConfig.systemKind : "";
|
||||
if (connection.driver_profile === "kafka" || systemKind === "kafka") return "kafka";
|
||||
if (connection.driver_profile === "rocketmq" || systemKind === "rocketmq") return "rocketmq";
|
||||
if (connection.driver_profile === "rabbitmq" || systemKind === "rabbitmq") return "rabbitmq";
|
||||
if (connection.driver_profile === "pulsar" || systemKind === "pulsar") return "pulsar";
|
||||
}
|
||||
return connection.driver_profile || connection.db_type;
|
||||
}
|
||||
|
||||
export function tabIconClass(tab: QueryTab): string {
|
||||
if (tab.externalSqlFileMissing) return "text-amber-600 dark:text-amber-400";
|
||||
if (tab.mode === "mq") return "";
|
||||
if (tab.mode === "databases" || tab.mode === "objects") return "text-amber-500 dark:text-amber-400";
|
||||
if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "vector" || tab.mode === "redis" || tab.mode === "hbase" || tab.mode === "structure") return "text-emerald-600 dark:text-emerald-400";
|
||||
return "text-blue-600 dark:text-blue-400";
|
||||
}
|
||||
|
||||
export function tabColorStyle(tab: QueryTab, active: boolean, isClassic: boolean): CSSProperties | undefined {
|
||||
const color = connectionColor(tab.connectionId);
|
||||
if (!color) {
|
||||
if (isClassic) {
|
||||
return active ? { boxShadow: "inset 0 -2px 0 var(--ring)" } : undefined;
|
||||
}
|
||||
return active ? { borderColor: "var(--ring)" } : undefined;
|
||||
}
|
||||
if (isClassic) {
|
||||
return {
|
||||
"--app-tab-background": hexToRgba(color, active ? 0.16 : 0.07),
|
||||
"--app-tab-hover-background": hexToRgba(color, 0.14),
|
||||
boxShadow: active ? `inset 0 -2px 0 ${color}` : undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
"--app-tab-background": hexToRgba(color, active ? 0.16 : 0.09),
|
||||
"--app-tab-hover-background": hexToRgba(color, 0.16),
|
||||
borderColor: active ? hexToRgba(color, 0.72) : hexToRgba(color, 0.18),
|
||||
};
|
||||
}
|
||||
|
||||
export function dirtyTabTitleStyle(isDirty: boolean): CSSProperties | undefined {
|
||||
if (!isDirty) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
fontStyle: "italic",
|
||||
fontWeight: 700,
|
||||
transform: "skewX(-8deg)",
|
||||
transformOrigin: "left center",
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user