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

This commit is contained in:
Lan-zk authored and GitHub committed 2026-09-04 14:44:03 +08:00
1 parent 24a274bd89
commit 5a3a6e2bb0
76 files changed
+7997 -3343

No files matched your search

@@ -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);
});
});
+23
View File
@@ -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
View File
@@ -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;
+6 -4
View File
@@ -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;
}
+2 -4
View File
@@ -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;
}
+28
View File
@@ -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;
}
}
+82
View File
@@ -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,
};
}
+60 -1
View File
@@ -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",
};
}