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

@@ -283,6 +283,16 @@ async function flushUi() {
}
}
// flushUi only drains microtasks; popovers that mount through a timer need
// polling or the query below races the render and returns null under load.
async function waitForElement<T extends Element>(selector: string): Promise<T> {
return vi.waitFor(() => {
const element = document.body.querySelector<T>(selector);
if (!element) throw new Error(`element not rendered yet: ${selector}`);
return element;
});
}
function buttonWithTitle(title: string): HTMLButtonElement {
const button = document.body.querySelector<HTMLElement>(`[title="${title}"]`)?.closest<HTMLButtonElement>("button") ?? null;
expect(button).not.toBeNull();
@@ -1082,7 +1092,7 @@ describe("DocumentBrowser MongoDB filter value types", () => {
root!.querySelector<HTMLButtonElement>('[data-testid="data-grid"] button')!.click();
await flushUi();
const valueInput = document.body.querySelector<HTMLInputElement>('input[placeholder="grid.filterBuilderValue"]')!;
const valueInput = await waitForElement<HTMLInputElement>('input[placeholder="grid.filterBuilderValue"]');
const callsBeforeEnter = backend.documentFindDocuments.mock.calls.length;
valueInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
await flushUi();
@@ -1090,7 +1100,7 @@ describe("DocumentBrowser MongoDB filter value types", () => {
root!.querySelector<HTMLButtonElement>('[data-testid="data-grid"] button')!.click();
await flushUi();
const reopenedValueInput = document.body.querySelector<HTMLInputElement>('input[placeholder="grid.filterBuilderValue"]')!;
const reopenedValueInput = await waitForElement<HTMLInputElement>('input[placeholder="grid.filterBuilderValue"]');
reopenedValueInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", shiftKey: true, bubbles: true, cancelable: true }));
await flushUi();
expect(document.body.querySelectorAll('input[placeholder="grid.filterBuilderValue"]')).toHaveLength(2);
@@ -43,7 +43,8 @@ describe("QueryEditor search shortcuts", () => {
expect(queryEditorSource).toMatch(/\.\.\.binding\(shortcuts\.sendSelectionToAi[\s\S]*\.\.\.createQueryEditorSearchKeymap/);
expect(queryEditorSource).not.toMatch(/Prec\.highest\(\s*keymap\.of\(\[\s*\.\.\.createQueryEditorSearchKeymap/);
expect(queryEditorSource).toMatch(/function openReplace\(\): boolean \{\s*if \(props\.readOnly\) return false;/);
expect(contentAreaSource).toContain('if (props.activeTab.mode === "query") return queryEditorRef.value?.openSearch() ?? false;');
expect(contentAreaSource).toContain("if (props.resultOnly) return dataGridRef.value?.focusSearch() ?? false;");
expect(contentAreaSource).toContain("return queryEditorRef.value?.openSearch() ?? false;");
expect(contentAreaSource).toContain("return queryEditorRef.value?.openReplace() ?? false;");
expect(queryEditorSource).toMatch(/key:\s*"Escape"/);
expect(editorSearchPanelSource).toContain('e.key === "Enter" && !e.shiftKey');
File diff suppressed because it is too large. Load diff
+151 -109
View File
@@ -121,6 +121,7 @@ const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explai
const QueryChart = defineAsyncComponent(() => import("@/components/chart/QueryChart.vue"));
import { useQueryStore } from "@/stores/queryStore";
import { useConnectionStore } from "@/stores/connectionStore";
import type { ContentAreaSurfaceEmits, ContentAreaSurfaceProps } from "@/components/layout/querySurfaces";
import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore, type DataGridSearchMode, type ResultRunDisplayMode } from "@/stores/settingsStore";
import { useToast } from "@/composables/useToast";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
@@ -151,14 +152,15 @@ import { chartableColumnIndexes } from "@/lib/dataGrid/chartData";
import { elasticsearchJsonResponseForResult } from "@/lib/elasticsearch/elasticsearchJsonResponse";
import * as api from "@/lib/backend/api";
import { applyMongoGridChangesToDocument, applyMongoGridChangesToDocumentBaseline, buildMongoUpdateDocument, formatMongoShellLiteral, serializeMongoDocumentId, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
import type { SqlExecutionOverride } from "@/lib/sql/sqlExecutionTarget";
import type { DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
import { isDataGridToolbarCompact, type DataGridReloadIntent } from "@/lib/dataGrid/dataGridToolbar";
import { useTabScroll } from "@/composables/useTabScroll";
import { useToolbarOverflow } from "@/composables/useToolbarOverflow";
import ToolbarOverflowMenu from "@/components/ui/ToolbarOverflowMenu.vue";
import { formatElapsedSeconds } from "@/lib/common/elapsedTime";
import { copyToClipboard } from "@/lib/common/clipboard";
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
import type { QueryTab, ConnectionConfig, TableInfoTab, TreeNode, VectorCollectionMeta, ObjectBrowserViewport } from "@/types/database";
import type { QueryTab, TableInfoTab, TreeNode, VectorCollectionMeta } from "@/types/database";
import type { SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
@@ -199,53 +201,18 @@ type ElasticsearchJsonResponsePanelHandle = {
focusSearch: () => boolean;
};
const props = defineProps<{
activeTab: QueryTab;
activeConnection?: ConnectionConfig;
executableSql: string;
activeOutputView: "result" | "summary" | "explain" | "chart" | "messages";
formatSqlRequest: { id: number; tabId: string } | null;
compressSqlRequest: { id: number; tabId: string } | null;
selectedSql: string;
cursorPos: number;
blockDangerousRedisCommands: boolean;
zenMode?: boolean;
}>();
const props = defineProps<
ContentAreaSurfaceProps & {
/** Render only the query editor pane (used by QueryEditorSurface). */
editorOnly?: boolean;
/** Render only the shared query result pane (used by QueryResultSurface). */
resultOnly?: boolean;
/** Whether the query editor should request focus on mount. */
autoFocus?: boolean;
}
>();
const emit = defineEmits<{
"update:activeOutputView": [value: "result" | "summary" | "explain" | "chart" | "messages"];
fixWithAi: [errorMessage: string];
sendSelectionToAi: [sql: string];
execute: [sqlOverride?: SqlExecutionOverride];
executeInNewResultTab: [sqlOverride?: SqlExecutionOverride];
saveSql: [];
cancel: [];
explain: [];
editorUpdate: [tabId: string, value: string];
editorSelectionChange: [value: string];
editorCursorChange: [pos: number];
previewChangesAvailable: [value: boolean];
editorViewportChange: [tabId: string, viewport: { scrollTop: number; scrollLeft: number }];
editorSelectionStateChange: [tabId: string, selection: { anchor: number; head: number }];
formatError: [];
reload: [sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent];
paginate: [offset: number, limit: number, whereInput?: string, orderBy?: string];
sort: [column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode?: DataGridSortMode];
executeSql: [sql: string];
clickTable: [target: SqlObjectNavigationTarget];
viewTableData: [target: SqlObjectNavigationTarget];
viewTableDdl: [target: SqlObjectNavigationTarget];
editTableStructure: [target: SqlObjectNavigationTarget];
openObjectSource: [target: SqlObjectNavigationTarget, initialEditing: boolean];
openObjectTable: [target: { tableName: string; schema?: string; tableType?: string; catalog?: string }];
objectSchemaChange: [schema: string | undefined];
objectBrowserViewportChange: [tabId: string, viewport: ObjectBrowserViewport];
structureEditorSaved: [commentChanged: boolean];
structureEditorClose: [];
openSettings: [initialTab?: string, initialSection?: string];
openConnectionSettings: [connectionId: string, initialTab: "advanced"];
toggleZenMode: [];
}>();
const emit = defineEmits<ContentAreaSurfaceEmits>();
const { t, locale } = useI18n();
const queryStore = useQueryStore();
@@ -300,6 +267,16 @@ const standaloneResultToolbarWidth = ref(0);
const standaloneResultToolbarViewportWidth = ref(0);
const resultTabsScrollerRef = ref<HTMLElement | null>(null);
const dataGridViewOptionsOpen = ref(false);
// Data-mode header condensation (same measured-tier mechanism as the SQL
// editor toolbar). The chips keep a min-width floor so scrollWidth reports
// real overflow; tiers then drop button labels and move secondary actions
// into the overflow menu instead of crushing the chips away.
const dataToolbarRef = ref<HTMLElement | null>(null);
const { tier: dataToolbarTier } = useToolbarOverflow(dataToolbarRef, [() => props.activeTab.id, () => !!props.activeTab.result]);
const dataToolbarCompact = computed(() => dataToolbarTier.value >= 1);
const showDataToolbarOverflow = computed(() => dataToolbarTier.value >= 2);
const showDataTableInfoButton = computed(() => dataToolbarTier.value < 2);
const showDataColumnsChip = computed(() => dataToolbarTier.value < 2);
const dataGridRenderMode = computed(() => settingsStore.editorSettings.dataGridRenderMode);
const dataGridSearchMode = computed(() => settingsStore.editorSettings.dataGridSearchMode);
const resultRunDisplayMode = computed(() => settingsStore.editorSettings.resultRunDisplayMode);
@@ -728,10 +705,14 @@ watch(
watch(
() => [props.activeTab.id, props.activeTab.result, props.activeTab.results, props.activeTab.isExecuting, props.activeOutputView] as const,
() => {
// The output view belongs to the shared result surface. An editor-only
// surface (a background group's editor) must never redirect the global
// view when its own tab finishes executing.
if (props.editorOnly) return;
if (props.activeTab.isExecuting) return;
if (hasExecutionSummary.value && !hasTabularResult.value && props.activeOutputView === "result") {
const result = props.activeTab.result;
emit("update:activeOutputView", result ? defaultViewForResult(result) : "summary");
emit("update:activeOutputView", props.activeTab.id, result ? defaultViewForResult(result) : "summary");
}
},
{ immediate: true },
@@ -869,23 +850,23 @@ function closeColumnInfo() {
}
function onHandleClickTable(target: SqlObjectNavigationTarget) {
emit("clickTable", target);
emit("clickTable", props.activeTab.id, target);
}
function onHandleViewTableData(target: SqlObjectNavigationTarget) {
emit("viewTableData", target);
emit("viewTableData", props.activeTab.id, target);
}
function onHandleViewTableDdl(target: SqlObjectNavigationTarget) {
emit("viewTableDdl", target);
emit("viewTableDdl", props.activeTab.id, target);
}
function onHandleEditTableStructure(target: SqlObjectNavigationTarget) {
emit("editTableStructure", target);
emit("editTableStructure", props.activeTab.id, target);
}
function onHandleOpenObjectSource(target: SqlObjectNavigationTarget, initialEditing: boolean) {
emit("openObjectSource", target, initialEditing);
emit("openObjectSource", props.activeTab.id, target, initialEditing);
}
function onHandleCloseColumnPanel() {
@@ -903,7 +884,12 @@ function focusSearch(): boolean {
if (props.activeTab.mode === "consul") return consulWorkspaceRef.value?.focusSearch() ?? false;
if (props.activeTab.mode === "databases") return databaseBrowserRef.value?.focusSearch() ?? false;
if (props.activeTab.mode === "objects") return objectBrowserRef.value?.focusSearch() ?? false;
if (props.activeTab.mode === "query") return queryEditorRef.value?.openSearch() ?? false;
if (props.activeTab.mode === "query") {
// The shared result surface (resultOnly) owns the grid, not the editor;
// route its search to the DataGrid instead of the missing QueryEditor.
if (props.resultOnly) return dataGridRef.value?.focusSearch() ?? false;
return queryEditorRef.value?.openSearch() ?? false;
}
return dataGridRef.value?.focusSearch() ?? false;
}
@@ -920,7 +906,7 @@ function refreshQueryEditorCompletionCache(): boolean {
function reloadUnavailableDataTab() {
const { whereInput, orderBy } = restoredDataTabReloadFilters(props.activeTab);
emit("reload", undefined, undefined, whereInput, orderBy);
emit("reload", props.activeTab.id, undefined, whereInput, orderBy);
}
function refreshData(): boolean {
@@ -940,7 +926,7 @@ function refreshData(): boolean {
if (activeElasticsearchJsonResponse.value) {
// Match DataGrid's toolbar refresh intent so multi-result runs are
// refreshed as a group instead of replacing them with the active result.
emit("reload", activeResultSql.value, undefined, undefined, undefined, undefined, undefined, "refresh");
emit("reload", props.activeTab.id, activeResultSql.value, undefined, undefined, undefined, undefined, undefined, "refresh");
return true;
}
if (!dataGridRef.value) return false;
@@ -976,7 +962,7 @@ async function removeResultRun(runId: string) {
const removedActiveRun = props.activeTab.activeResultRunId === runId;
const removed = await queryStore.removeResultRun(props.activeTab.id, runId);
if (!removed) return;
if (removedActiveRun) emit("update:activeOutputView", "result");
if (removedActiveRun) emit("update:activeOutputView", props.activeTab.id, "result");
await nextTick();
const activeRunTab = resultTabsScrollerRef.value?.querySelector<HTMLElement>('[data-active-result-run="true"]');
activeRunTab?.focus({ preventScroll: true });
@@ -1040,7 +1026,7 @@ function resultRunContextMenuItems(run: (typeof resultRuns.value)[number]): Cont
async function closeCurrentQueryResult() {
if (!(await queryStore.closeQueryResult(props.activeTab.id))) return;
emit("update:activeOutputView", "result");
emit("update:activeOutputView", props.activeTab.id, "result");
}
async function selectResultRun(runId: string) {
@@ -1048,7 +1034,7 @@ async function selectResultRun(runId: string) {
toast(t("tabs.missingResultRun"), 4000);
return false;
}
emit("update:activeOutputView", "result");
emit("update:activeOutputView", props.activeTab.id, "result");
return true;
}
@@ -1082,10 +1068,13 @@ function toggleResultAutoSave() {
function selectResultItem(item: (typeof visibleResultItems.value)[number]) {
queryStore.setActiveResultIndex(props.activeTab.id, item.index);
emit("update:activeOutputView", "result");
nextTick(() => {
queryEditorRef.value?.previewStatementRange(resultSourceRange(props.activeTab.sql, item.result, item.index, activeEffectiveDatabaseType.value) ?? null);
});
emit("update:activeOutputView", props.activeTab.id, "result");
const range = resultSourceRange(props.activeTab.sql, item.result, item.index, activeEffectiveDatabaseType.value) ?? null;
if (queryEditorRef.value) {
nextTick(() => queryEditorRef.value?.previewStatementRange(range));
} else {
emit("previewStatement", props.activeTab.id, range);
}
}
function executionSummaryItemRange(item: ExecutionSummaryItem) {
@@ -1096,11 +1085,21 @@ function executionSummaryItemRange(item: ExecutionSummaryItem) {
}
function previewExecutionSummaryItem(item: ExecutionSummaryItem) {
queryEditorRef.value?.previewStatementRange(executionSummaryItemRange(item) ?? null);
const range = executionSummaryItemRange(item) ?? null;
if (queryEditorRef.value) {
queryEditorRef.value?.previewStatementRange(range);
} else {
emit("previewStatement", props.activeTab.id, range);
}
}
function focusExecutionSummaryItem(item: ExecutionSummaryItem) {
queryEditorRef.value?.focusStatementRange(executionSummaryItemRange(item) ?? null);
const range = executionSummaryItemRange(item) ?? null;
if (queryEditorRef.value) {
queryEditorRef.value?.focusStatementRange(range);
} else {
emit("focusStatement", props.activeTab.id, range);
}
}
async function copyExecutionSummaryError(error: string) {
@@ -1188,6 +1187,18 @@ async function executeRedisCommand(command: string): Promise<boolean> {
return (await redisKeyBrowserRef.value?.executeCommand?.(command)) ?? false;
}
function previewStatementRange(range: { from: number; to: number } | null): boolean {
if (!queryEditorRef.value) return false;
queryEditorRef.value.previewStatementRange(range);
return true;
}
function focusStatementRange(range: { from: number; to: number } | null): boolean {
if (!queryEditorRef.value) return false;
queryEditorRef.value.focusStatementRange(range);
return true;
}
defineExpose({
focusSearch,
openGoToColumn,
@@ -1206,11 +1217,13 @@ defineExpose({
applyTableStructureChanges,
insertRedisCommand,
executeRedisCommand,
previewStatementRange,
focusStatementRange,
});
</script>
<template>
<div class="production-session-shell flex flex-col flex-1 min-h-0" :class="{ 'production-session-shell--active': activeProductionContext.active }">
<div class="production-session-shell flex flex-col min-h-0" :class="[resultOnly ? 'h-full' : 'flex-1', { 'production-session-shell--active': activeProductionContext.active }]">
<div v-if="activeProductionContext.active" class="production-session-strip flex h-7 shrink-0 items-center gap-2 border-b border-red-500/35 bg-red-500/10 px-3 text-xs font-semibold text-red-800 shadow-[inset_0_1px_0_rgb(239_68_68_/_0.28)] dark:text-red-200">
<ShieldAlert class="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span class="font-mono uppercase tracking-normal">{{ t("production.title") }}</span>
@@ -1219,7 +1232,7 @@ defineExpose({
<!-- Query mode: editor + results -->
<template v-if="activeTab.mode === 'query'">
<Splitpanes horizontal class="query-output-splitpanes flex-1 min-h-0 overflow-hidden" @resized="onResultsResized">
<Pane class="min-h-0" :size="editorPaneSize" :min-size="resultsPaneOpen ? 15 : 100">
<Pane v-if="!resultOnly" class="min-h-0" :size="editorPaneSize" :min-size="resultsPaneOpen ? 15 : 100">
<div class="h-full flex flex-col relative">
<div v-if="activeProductionContext.active" class="production-watermark pointer-events-none absolute inset-0 z-10 grid select-none" aria-hidden="true">
<span v-for="index in 4" :key="index" class="production-watermark__label whitespace-nowrap font-mono text-6xl font-extrabold text-red-700/[0.12] dark:text-red-200/[0.1]">{{ productionWatermarkText }}</span>
@@ -1227,7 +1240,7 @@ defineExpose({
<QueryEditor
ref="queryEditorRef"
class="relative z-0 flex-1"
auto-focus
:auto-focus="autoFocus !== false"
:model-value="activeTab.sql"
:connection-id="activeTab.connectionId"
:catalog="activeTab.catalog"
@@ -1252,17 +1265,17 @@ defineExpose({
:initial-selection="activeTab.editorSelection"
:force-word-wrap="activeTab.forceWordWrap"
@update:model-value="emit('editorUpdate', activeTab.id, $event)"
@selection-change="emit('editorSelectionChange', $event)"
@send-selection-to-ai="emit('sendSelectionToAi', $event)"
@cursor-change="emit('editorCursorChange', $event)"
@preview-changes-available="emit('previewChangesAvailable', $event)"
@selection-change="emit('editorSelectionChange', activeTab.id, $event)"
@send-selection-to-ai="emit('sendSelectionToAi', activeTab.id, $event)"
@cursor-change="emit('editorCursorChange', activeTab.id, $event)"
@preview-changes-available="emit('previewChangesAvailable', activeTab.id, $event)"
@viewport-change="emit('editorViewportChange', activeTab.id, $event)"
@selection-state-change="emit('editorSelectionStateChange', activeTab.id, $event)"
@format-error="emit('formatError')"
@execute="emit('execute', $event)"
@execute-in-new-result-tab="emit('executeInNewResultTab', $event)"
@format-error="emit('formatError', activeTab.id)"
@execute="emit('execute', activeTab.id, $event)"
@execute-in-new-result-tab="emit('executeInNewResultTab', activeTab.id, $event)"
@export-query="handleExportQuery"
@save="emit('saveSql')"
@save="emit('saveSql', props.activeTab.id)"
@click-table="onHandleClickTable"
@view-table-data="onHandleViewTableData"
@edit-table-structure="onHandleEditTableStructure"
@@ -1280,13 +1293,13 @@ defineExpose({
:is-gaussdb-m="activeEffectiveDatabaseType === 'gaussdb' && activeResultConnection?.driver_profile?.toLowerCase() === 'gaussdb-m'"
@close="closeColumnInfo"
/>
<Button v-if="hasQueryOutput && !resultsPaneOpen" variant="secondary" size="sm" class="absolute bottom-3 right-3 z-20 h-7 gap-1.5 rounded-full border bg-background/95 px-3 text-xs shadow-lg hover:bg-accent" @click="resultsPaneOpen = true">
<Button v-if="!editorOnly && hasQueryOutput && !resultsPaneOpen" variant="secondary" size="sm" class="absolute bottom-3 right-3 z-20 h-7 gap-1.5 rounded-full border bg-background/95 px-3 text-xs shadow-lg hover:bg-accent" @click="resultsPaneOpen = true">
<ChevronUp class="h-3.5 w-3.5" />
{{ t("editor.showResultsPane") }}
</Button>
</div>
</Pane>
<Pane v-if="resultsPaneOpen" class="min-h-0" :size="resultsPaneSize" :min-size="20">
<Pane v-if="(resultsPaneOpen || resultOnly) && !editorOnly" class="min-h-0" :size="resultOnly ? 100 : resultsPaneSize" :min-size="resultOnly ? 100 : 20">
<div class="h-full flex flex-col">
<div v-if="hasQueryOutput" class="flex h-10 shrink-0 items-center gap-1 border-b bg-muted/20 px-2">
<Button
@@ -1642,7 +1655,7 @@ defineExpose({
:can-show-messages="canShowMessagesOutput"
:message-count="resultMessageCount"
:compact="standaloneResultToolbarCompact"
@select-view="emit('update:activeOutputView', $event)"
@select-view="emit('update:activeOutputView', activeTab.id, $event)"
/>
<QueryResultToolbarActions
class="ml-auto"
@@ -1651,7 +1664,7 @@ defineExpose({
:can-export-archive="canExportResultArchive"
:archive-exporting="resultArchiveExporting"
:compact="standaloneResultToolbarCompact"
@select-explain="emit('update:activeOutputView', 'explain')"
@select-explain="emit('update:activeOutputView', activeTab.id, 'explain')"
@export-archive="exportResultArchive"
/>
</div>
@@ -1821,7 +1834,7 @@ defineExpose({
:total-row-count-is-exact="activeTab.resultTotalRowCount !== undefined || activeTab.result.total_is_exact !== false"
:total-row-count-loading="activeTab.resultTotalRowCountLoading"
:page-jump-progress="activeTab.resultPageJumpProgress"
:on-execute-sql="async (sql: string) => emit('executeSql', sql)"
:on-execute-sql="async (sql: string) => emit('executeSql', activeTab.id, sql)"
:full-export-result="(onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => queryStore.fetchTabResultForExport(activeTab.id, onProgress)"
:query-result-export-request="
(options: { exportId: string; filePath: string; format: 'csv' | 'xlsx' | 'txt' | 'sql'; includeSqlSheet?: boolean; exportTableName?: string; exportColumnTypes?: Array<string | null | undefined> }) => queryStore.buildQueryResultExportRequest(activeTab.id, options)
@@ -1830,9 +1843,9 @@ defineExpose({
:export-file-base-name="activeTab.title"
@update:order-by-input="(v: string) => (activeTab.orderByInput = v)"
@local-column-filters-change="(filters: Record<string, string[]>) => queryStore.updateDataGridLocalColumnFilters(activeTab.id, filters)"
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent) => emit('reload', sql, searchText, whereInput, orderBy, limit, offset, intent)"
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string) => emit('paginate', offset, limit, whereInput, orderBy)"
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => emit('sort', column, columnIndex, direction, whereInput, mode)"
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent) => emit('reload', activeTab.id, sql, searchText, whereInput, orderBy, limit, offset, intent)"
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string) => emit('paginate', activeTab.id, offset, limit, whereInput, orderBy)"
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => emit('sort', activeTab.id, column, columnIndex, direction, whereInput, mode)"
@change-query-timeout="(connectionId: string) => emit('openConnectionSettings', connectionId, 'advanced')"
>
<template #result-toolbar-leading="{ compact }">
@@ -1844,7 +1857,7 @@ defineExpose({
:can-show-messages="canShowMessagesOutput"
:message-count="resultMessageCount"
:compact="compact"
@select-view="emit('update:activeOutputView', $event)"
@select-view="emit('update:activeOutputView', activeTab.id, $event)"
/>
<template v-if="activeElasticsearchRawBody">
<div class="mx-1 h-4 w-px bg-border" />
@@ -1867,7 +1880,7 @@ defineExpose({
:can-export-archive="canExportResultArchive"
:archive-exporting="resultArchiveExporting"
:compact="compact"
@select-explain="emit('update:activeOutputView', 'explain')"
@select-explain="emit('update:activeOutputView', activeTab.id, 'explain')"
@export-archive="exportResultArchive"
/>
</template>
@@ -1878,7 +1891,7 @@ defineExpose({
:connection-id="activeResultConnectionId"
@change-connection-timeout="activeResultConnectionId && emit('openConnectionSettings', activeResultConnectionId, 'advanced')"
@change-query-timeout="activeResultConnectionId && emit('openConnectionSettings', activeResultConnectionId, 'advanced')"
@fix-with-ai="(message) => emit('fixWithAi', message)"
@fix-with-ai="(message) => emit('fixWithAi', activeTab.id, message)"
/>
</template>
</DataGrid>
@@ -1890,7 +1903,7 @@ defineExpose({
show-cancel
:cancel-disabled="!canCancelQueryExecution(activeTab)"
:cancelling="activeTab.isCancelling"
@cancel="emit('cancel')"
@cancel="emit('cancel', activeTab.id)"
/>
<div v-else-if="activeTab.resultEvicted && activeTab.resultCacheState === 'missing'" class="flex flex-1 min-h-0 flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
<div>{{ t("grid.cachedResultUnavailable") }}</div>
@@ -1911,27 +1924,41 @@ defineExpose({
<!-- Data mode: full-height grid -->
<template v-else-if="activeTab.mode === 'data'">
<div class="flex-1 min-h-0 flex flex-col">
<div class="h-9 shrink-0 border-b bg-background/80 px-3 flex items-center gap-2 text-xs">
<!-- No fixed max-w cap on these chips: they must flex (min-w-0 + truncate)
so long names only clip when the header row itself runs out of space (#7880). -->
<span v-if="activeConnection?.name?.trim()" data-data-header-connection class="inline-flex min-w-0 items-center truncate rounded border border-border bg-muted/30 px-2 py-0.5 text-muted-foreground" :title="activeConnection.name">
<div ref="dataToolbarRef" class="h-9 shrink-0 border-b bg-background/80 px-3 flex items-center gap-2 text-xs overflow-hidden">
<!-- No fixed max-w cap on these chips: they must flex (truncate)
so long names only clip when the header row itself runs out
(#7880). The min-w floor keeps scrollWidth reporting real
overflow so the measured tiers condense the row before the
chips collapse. -->
<span v-if="activeConnection?.name?.trim()" data-data-header-connection class="inline-flex min-w-12 items-center truncate rounded border border-border bg-muted/30 px-2 py-0.5 text-muted-foreground" :title="activeConnection.name">
{{ activeConnection.name }}
</span>
<span class="inline-flex min-w-0 items-center truncate rounded border border-border bg-muted/50 px-2 py-0.5 font-medium" :title="activeTab.tableMeta?.tableName || activeTab.title">
<span class="inline-flex min-w-12 items-center truncate rounded border border-border bg-muted/50 px-2 py-0.5 font-medium" :title="activeTab.tableMeta?.tableName || activeTab.title">
{{ activeTab.tableMeta?.tableName || activeTab.title }}
</span>
<span class="inline-flex min-w-0 items-center truncate rounded border border-border bg-muted/30 px-2 py-0.5 text-muted-foreground" :title="[activeTab.tableMeta?.schema, databaseDisplayNameForTab(activeTab.connectionId, activeTab.database, t)].filter(Boolean).join('@')">
<span class="inline-flex min-w-12 items-center truncate rounded border border-border bg-muted/30 px-2 py-0.5 text-muted-foreground" :title="[activeTab.tableMeta?.schema, databaseDisplayNameForTab(activeTab.connectionId, activeTab.database, t)].filter(Boolean).join('@')">
<template v-if="activeTab.tableMeta?.schema">{{ activeTab.tableMeta.schema }}@</template>{{ databaseDisplayNameForTab(activeTab.connectionId, activeTab.database, t) }}
</span>
<span v-if="activeTab.mode === 'data' && activeTab.tableMeta" class="inline-flex shrink-0 items-center rounded border border-border bg-muted/30 px-2 py-0.5 font-medium text-muted-foreground tabular-nums"> {{ activeTab.tableMeta.columns.length }} {{ t("tree.columns") }} </span>
<span v-if="showDataColumnsChip && activeTab.mode === 'data' && activeTab.tableMeta" class="inline-flex shrink-0 items-center rounded border border-border bg-muted/30 px-2 py-0.5 font-medium text-muted-foreground tabular-nums">
{{ activeTab.tableMeta.columns.length }} {{ t("tree.columns") }}
</span>
<span class="ml-auto" />
<DataGridColumnLayoutPopover v-if="activeTab.result?.columns.length" :grid="dataGridRef" trigger-class="px-1.5" />
<Button v-if="activeTab.result && activeTab.tableMeta && activeTab.connectionId" variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0" :class="{ 'bg-accent': dataGridRef?.showDdl }" @click="dataGridRef?.toggleDdl()"
><TableProperties class="h-3.5 w-3.5" />{{ t("grid.tableInfo") }}</Button
<Button
v-if="showDataTableInfoButton && activeTab.result && activeTab.tableMeta && activeTab.connectionId"
variant="ghost"
size="sm"
class="h-5 text-xs px-1.5 shrink-0"
:class="{ 'bg-accent': dataGridRef?.showDdl }"
:title="dataToolbarCompact ? t('grid.tableInfo') : undefined"
@click="dataGridRef?.toggleDdl()"
><TableProperties class="h-3.5 w-3.5" /><span v-if="!dataToolbarCompact">{{ t("grid.tableInfo") }}</span></Button
>
<DropdownMenu v-if="activeTab.result && activeTab.tableMeta && activeTab.connectionId">
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0" :title="t('tableToolbox.title')"><Toolbox class="h-3.5 w-3.5" />{{ t("tableToolbox.title") }}</Button>
<Button variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0" :title="t('tableToolbox.title')"
><Toolbox class="h-3.5 w-3.5" /><span v-if="!dataToolbarCompact">{{ t("tableToolbox.title") }}</span></Button
>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-max min-w-44 gap-0 overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-xl">
<div class="border-b bg-muted/40 px-3 py-2">
@@ -2169,6 +2196,12 @@ defineExpose({
/>
</PopoverContent>
</Popover>
<ToolbarOverflowMenu v-if="showDataToolbarOverflow" :label="t('toolbar.moreActions')">
<DropdownMenuItem v-if="activeTab.result && activeTab.tableMeta && activeTab.connectionId" @select="dataGridRef?.toggleDdl()">
<TableProperties class="h-3.5 w-3.5" />
{{ t("grid.tableInfo") }}
</DropdownMenuItem>
</ToolbarOverflowMenu>
</div>
<DataGrid
v-if="activeTab.result"
@@ -2198,15 +2231,15 @@ defineExpose({
:total-row-count="activeTab.resultTotalRowCount"
:total-row-count-is-exact="activeTab.resultTotalRowCount !== undefined || activeTab.result.total_is_exact !== false"
:total-row-count-loading="activeTab.resultTotalRowCountLoading"
:on-execute-sql="async (sql: string) => emit('executeSql', sql)"
:on-execute-sql="async (sql: string) => emit('executeSql', activeTab.id, sql)"
:full-export-result="(onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => queryStore.fetchTabResultForExport(activeTab.id, onProgress)"
:export-file-base-name="activeTab.title"
@update:where-input="(v: string) => (activeTab.whereInput = v)"
@update:order-by-input="(v: string) => (activeTab.orderByInput = v)"
@local-column-filters-change="(filters: Record<string, string[]>) => queryStore.updateDataGridLocalColumnFilters(activeTab.id, filters)"
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent) => emit('reload', sql, searchText, whereInput, orderBy, limit, offset, intent)"
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string) => emit('paginate', offset, limit, whereInput, orderBy)"
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => emit('sort', column, columnIndex, direction, whereInput, mode)"
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent) => emit('reload', activeTab.id, sql, searchText, whereInput, orderBy, limit, offset, intent)"
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string) => emit('paginate', activeTab.id, offset, limit, whereInput, orderBy)"
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => emit('sort', activeTab.id, column, columnIndex, direction, whereInput, mode)"
@change-query-timeout="(connectionId: string) => emit('openConnectionSettings', connectionId, 'advanced')"
>
<template v-if="activeTab.result && isQueryExecutionErrorResult(activeTab.result)" #error-actions="{ errorMessage }">
@@ -2216,11 +2249,20 @@ defineExpose({
:connection-id="activeResultConnectionId"
@change-connection-timeout="activeResultConnectionId && emit('openConnectionSettings', activeResultConnectionId, 'advanced')"
@change-query-timeout="activeResultConnectionId && emit('openConnectionSettings', activeResultConnectionId, 'advanced')"
@fix-with-ai="(message) => emit('fixWithAi', message)"
@fix-with-ai="(message) => emit('fixWithAi', activeTab.id, message)"
/>
</template>
</DataGrid>
<QueryLoadingState v-else-if="activeTab.isExecuting" class="h-full" :label-key="queryExecutionLabelKey(activeTab)" :elapsed-seconds="queryRunningElapsedSeconds" show-cancel :cancel-disabled="!canCancelQueryExecution(activeTab)" :cancelling="activeTab.isCancelling" @cancel="emit('cancel')" />
<QueryLoadingState
v-else-if="activeTab.isExecuting"
class="h-full"
:label-key="queryExecutionLabelKey(activeTab)"
:elapsed-seconds="queryRunningElapsedSeconds"
show-cancel
:cancel-disabled="!canCancelQueryExecution(activeTab)"
:cancelling="activeTab.isCancelling"
@cancel="emit('cancel', activeTab.id)"
/>
<div v-else class="h-full flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm">
<Inbox class="h-8 w-8 opacity-60" />
<div>{{ t("grid.dataUnavailable") }}</div>
@@ -2394,8 +2436,8 @@ defineExpose({
:initial-event-create-request-id="activeTab.objectBrowser?.eventCreateRequestId"
:initial-object-filter="activeTab.objectBrowser?.initialObjectFilter"
:viewport="activeTab.objectBrowser?.viewport"
@open-table="emit('openObjectTable', $event)"
@schema-change="emit('objectSchemaChange', $event)"
@open-table="emit('openObjectTable', activeTab.id, $event)"
@schema-change="emit('objectSchemaChange', activeTab.id, $event)"
@viewport-change="emit('objectBrowserViewportChange', activeTab.id, $event)"
/>
</div>
@@ -2416,8 +2458,8 @@ defineExpose({
:initial-target="activeTab.structureInitialTarget"
:draft="activeTab.structureDraft"
@update:draft="(draft) => (activeTab.structureDraft = draft)"
@saved="(commentChanged) => emit('structureEditorSaved', commentChanged)"
@close="emit('structureEditorClose')"
@saved="(commentChanged) => emit('structureEditorSaved', activeTab.id, commentChanged)"
@close="emit('structureEditorClose', activeTab.id)"
@open-settings="(initialTab, initialSection) => emit('openSettings', initialTab, initialSection)"
/>
</template>
@@ -0,0 +1,205 @@
<script setup lang="ts">
import { computed, inject, ref, useAttrs } from "vue";
import { useI18n } from "vue-i18n";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import EditorGroupTabBar from "./EditorGroupTabBar.vue";
import EditorToolbar from "./EditorToolbar.vue";
import QueryEditorSurface from "./QueryEditorSurface.vue";
import ContentArea from "./ContentArea.vue";
import { createNoopEditorToolbarActions, EDITOR_TOOLBAR_ACTIONS } from "./editorToolbarActions";
import { createContentSurfaceEventForwarders } from "@/lib/tabs/contentSurfaceEvents";
import { isPreviewTab } from "@/lib/tabs/tabPresentation";
import { resolveExecutableSql } from "@/lib/sql/sqlExecutionTarget";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import type { ContentAreaSurfaceEmits, ContentAreaSurfaceProps, QueryEditorSurfaceHandle, StatementRange } from "./querySurfaces";
import type { QueryTab } from "@/types/database";
defineOptions({ inheritAttrs: false });
const props = defineProps<
ContentAreaSurfaceProps & {
groupId: string;
tabIds: string[];
activeTabId: string | null;
tabBarWidth?: number;
tabBarCollapsed?: boolean;
canDetachTabs?: boolean;
detachedDropTarget?: boolean;
}
>();
const emit = defineEmits<
ContentAreaSurfaceEmits & {
"focus-group": [groupId: string];
"activate-tab": [tabId: string];
"locate-tab": [tab: QueryTab];
"toggle-zen-mode": [];
"start-resize": [event: MouseEvent];
"toggle-collapse": [];
"detach-tab": [tab: QueryTab];
}
>();
const attrs = useAttrs();
const groupClass = computed(() => (typeof attrs.class === "string" ? attrs.class : undefined));
const surfaceProps = computed<ContentAreaSurfaceProps>(() => ({
activeTab: activeTab.value!,
activeConnection: activeConnection.value,
executableSql: props.executableSql,
activeOutputView: props.activeOutputView,
formatSqlRequest: props.formatSqlRequest,
compressSqlRequest: props.compressSqlRequest,
selectedSql: props.selectedSql,
cursorPos: props.cursorPos,
blockDangerousRedisCommands: props.blockDangerousRedisCommands,
zenMode: props.zenMode,
}));
const contentEmits = createContentSurfaceEventForwarders(emit);
const surfaceBindings = computed(() => ({ ...surfaceProps.value, ...contentEmits }));
const activeSurfaceRef = ref<QueryEditorSurfaceHandle | null>(null);
defineExpose({
focusSearch: () => activeSurfaceRef.value?.focusSearch() ?? false,
openGoToColumn: () => activeSurfaceRef.value?.openGoToColumn() ?? false,
refreshData: () => activeSurfaceRef.value?.refreshData() ?? false,
toggleResultsPane: () => activeSurfaceRef.value?.toggleResultsPane() ?? false,
refreshQueryEditorCompletionCache: () => activeSurfaceRef.value?.refreshQueryEditorCompletionCache() ?? false,
handleModRTarget: (target: Element) => activeSurfaceRef.value?.handleModRTarget(target) ?? false,
requestQueryEditorExecute: () => activeSurfaceRef.value?.requestQueryEditorExecute() ?? false,
captureQueryEditorExecutionSnapshot: () => activeSurfaceRef.value?.captureQueryEditorExecutionSnapshot(),
requestQueryEditorExecuteInNewResultTab: () => activeSurfaceRef.value?.requestQueryEditorExecuteInNewResultTab() ?? false,
requestQueryEditorPreviewChanges: async (stackSql?: string) => (await activeSurfaceRef.value?.requestQueryEditorPreviewChanges(stackSql)) ?? false,
shouldBlockQueryEditorExecutionShortcut: (event: KeyboardEvent) => activeSurfaceRef.value?.shouldBlockQueryEditorExecutionShortcut(event) ?? false,
cancelQueryEditorExecutionViewport: (requestId: number) => activeSurfaceRef.value?.cancelQueryEditorExecutionViewport(requestId) ?? false,
acceptQueryEditorExecutionViewport: (requestId: number) => activeSurfaceRef.value?.acceptQueryEditorExecutionViewport(requestId) ?? false,
pasteClipboardAsSqlInCondition: () => activeSurfaceRef.value?.pasteClipboardAsSqlInCondition() ?? Promise.resolve(false),
applyTableStructureChanges: () => activeSurfaceRef.value?.applyTableStructureChanges() ?? Promise.resolve(false),
insertRedisCommand: (command: string) => activeSurfaceRef.value?.insertRedisCommand(command) ?? Promise.resolve(false),
executeRedisCommand: (command: string) => activeSurfaceRef.value?.executeRedisCommand(command) ?? Promise.resolve(false),
previewStatementRange: (tabId: string, range: StatementRange | null) => (activeTab.value?.id === tabId ? (activeSurfaceRef.value?.previewStatementRange(range) ?? false) : false),
focusStatementRange: (tabId: string, range: StatementRange | null) => (activeTab.value?.id === tabId ? (activeSurfaceRef.value?.focusStatementRange(range) ?? false) : false),
});
const { t } = useI18n();
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const toolbar = inject(EDITOR_TOOLBAR_ACTIONS, createNoopEditorToolbarActions());
const groupTabs = computed(() => {
const byId = new Map(queryStore.tabs.map((tab) => [tab.id, tab]));
return props.tabIds.map((id) => byId.get(id)).filter((tab): tab is QueryTab => !!tab);
});
const activeTab = computed(() => groupTabs.value.find((tab) => tab.id === props.activeTabId) ?? groupTabs.value[0] ?? null);
const activeConnection = computed(() => (activeTab.value ? connectionStore.getConfig(activeTab.value.connectionId) : undefined));
const showGroupToolbar = computed(() => activeTab.value?.mode === "query" && !isPreviewTab(activeTab.value));
const isGroupOracleManualTransaction = computed(() => effectiveDatabaseTypeForConnection(activeConnection.value) === "oracle" && (activeTab.value?.autoCommit ?? true) === false);
// Each group previews the executable SQL of its own active tab (selection
// stored on the tab), not the focused tab's global selection.
// tabPlacement drives each pane's own bar position: the strip sits above,
// below, left, or right of the pane's content, uniformly across panes.
const groupLayoutClass = computed(() => {
switch (settingsStore.editorSettings.tabPlacement) {
case "bottom":
return "flex-col-reverse";
case "left":
return "flex-row";
case "right":
return "flex-row-reverse";
default:
return "flex-col";
}
});
const groupExecutableSql = computed(() => {
const tab = activeTab.value;
if (!tab || tab.mode !== "query") {
return "";
}
const selection = tab.editorSelection;
const selectedSql = selection && selection.anchor !== selection.head ? tab.sql.slice(Math.min(selection.anchor, selection.head), Math.max(selection.anchor, selection.head)) : "";
return resolveExecutableSql(tab.sql, selectedSql, {
mode: settingsStore.editorSettings.executeMode,
cursorPos: selection?.head ?? 0,
});
});
</script>
<template>
<div class="editor-group flex h-full min-h-0 min-w-0 overflow-hidden" :class="[groupClass, groupLayoutClass]" :data-group-id="groupId" @pointerdown.capture="$emit('focus-group', groupId)" @focusin="$emit('focus-group', groupId)">
<EditorGroupTabBar
:group-id="groupId"
:tabs="groupTabs"
:active-tab-id="activeTabId"
:tab-bar-width="tabBarWidth"
:tab-bar-collapsed="tabBarCollapsed"
:can-detach-tabs="canDetachTabs"
:detached-drop-target="detachedDropTarget"
@activate-tab="$emit('activate-tab', $event)"
@locate-tab="$emit('locate-tab', $event)"
@toggle-zen-mode="$emit('toggle-zen-mode')"
@start-resize="$emit('start-resize', $event)"
@toggle-collapse="$emit('toggle-collapse')"
@detach-tab="$emit('detach-tab', $event)"
/>
<!-- The toolbar stays at the top of the pane's content column in every
placement; only the tab bar moves around it. -->
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
<EditorToolbar
v-if="activeTab && showGroupToolbar"
:active-tab="activeTab"
:active-connection="activeConnection"
:executable-sql="groupExecutableSql"
:explain-mode="toolbar.explainMode.value"
:block-dangerous-redis-commands="toolbar.blockDangerousRedisCommands.value"
:sql-keyword-case="settingsStore.editorSettings.sqlFormatter.keywordCase"
:database-required-signal="toolbar.databaseRequiredSignalFor(activeTab.id)"
:auto-commit="activeTab.autoCommit ?? true"
:txn-session-id="activeTab.txnSessionId"
:txn-auto-rolled-back="activeTab.txnAutoRolledBack"
:oracle-txn-possibly-dirty="activeTab.oracleTxnPossiblyDirty"
:is-oracle-manual-transaction="isGroupOracleManualTransaction"
@update:explain-mode="(m: 'explain' | 'autotrace') => (toolbar.explainMode.value = m)"
@update:block-dangerous-redis-commands="(v: boolean) => (toolbar.blockDangerousRedisCommands.value = v)"
@update:auto-commit="
(v: boolean) => {
if (activeTab) {
queryStore.setAutoCommit(activeTab.id, v);
}
}
"
@commit="activeTab && queryStore.commitTransaction(activeTab.id)"
@rollback="activeTab && queryStore.rollbackTransaction(activeTab.id)"
@dismiss-txn-rolled-back="activeTab && (activeTab.txnAutoRolledBack = false)"
@execute-pointer-down="toolbar.captureExecutionSnapshot()"
@toolbar-execute="toolbar.toolbarExecute($event)"
@multi-execute="toolbar.multiExecute()"
@preview-changes="activeTab && toolbar.previewChanges(activeTab.id)"
@cancel="activeTab && toolbar.cancelExecution(activeTab.id)"
@explain="activeTab && toolbar.explain(activeTab.id)"
@format-sql="activeTab && toolbar.formatSql(activeTab.id)"
@compress-sql="activeTab && toolbar.compressSql(activeTab.id)"
@toggle-sql-keyword-case="toolbar.toggleSqlKeywordCase()"
@save-sql="(tabId: string) => toolbar.saveSql(tabId)"
@open-sql="toolbar.openSqlFile()"
@import-result-archive="toolbar.importResultArchive()"
@paste-sql-in-condition="toolbar.pasteSqlInCondition()"
@change-connection="(connectionId: string) => activeTab && toolbar.changeConnection(activeTab.id, connectionId)"
@change-database="(database: string) => activeTab && toolbar.changeDatabase(activeTab.id, database)"
@change-catalog="(catalog: string | undefined, database: string) => activeTab && toolbar.changeCatalog(activeTab.id, catalog, database)"
@change-schema="(schema: string | undefined) => activeTab && toolbar.changeSchema(activeTab.id, schema)"
@set-default-database="activeTab && toolbar.setDefaultDatabase(activeTab.id)"
@clear-default-database="activeTab && toolbar.clearDefaultDatabase(activeTab.id)"
/>
<div class="relative flex-1 min-h-0">
<QueryEditorSurface v-if="activeTab?.mode === 'query'" ref="activeSurfaceRef" v-bind="surfaceBindings" :auto-focus="groupId === queryStore.focusedGroupId" class="h-full" />
<ContentArea v-else-if="activeTab" ref="activeSurfaceRef" v-bind="surfaceBindings" class="h-full" />
<div v-else class="flex h-full items-center justify-center text-sm text-muted-foreground">
{{ t("tabs.emptyGroup") }}
</div>
</div>
</div>
</div>
</template>
File diff suppressed because it is too large. Load diff
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed, ref, watch, watchEffect } from "vue";
import { computed, nextTick, onUnmounted, ref, watch, watchEffect } from "vue";
import { useI18n } from "vue-i18n";
import { Play, CirclePlay, Loader2, Square, Database, Check, Table2, AlignLeft, GitBranch, Save, FolderOpen, X, Shield, Download, RotateCcw, AlertTriangle, ClipboardPaste, Minimize2, SpellCheck2, BetweenVerticalStart, Eye } from "@lucide/vue";
import { Play, CirclePlay, Loader2, Square, Database, Check, Table2, AlignLeft, GitBranch, Save, FolderOpen, X, Shield, Download, RotateCcw, AlertTriangle, ClipboardPaste, Minimize2, SpellCheck2, Layers, MoreHorizontal, BetweenVerticalStart, Eye } from "@lucide/vue";
import { supportsInsertValueHints } from "@/lib/editor/codemirrorInsertValueHints";
import { Button } from "@/components/ui/button";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import TruncatedTextTooltip from "@/components/ui/TruncatedTextTooltip.vue";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
@@ -22,6 +23,7 @@ import { connectionIsDorisFamilyCatalogCapable } from "@/lib/database/databaseFe
import { hexToRgba } from "@/lib/common/color";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay";
import { resolveNextEditorToolbarTier, type EditorToolbarTier } from "@/lib/tabs/editorToolbarLayout";
import { looksLikeDmlStatement } from "@/lib/sql/dmlChangePreview";
import type { QueryTab, ConnectionConfig } from "@/types/database";
@@ -47,7 +49,7 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
execute: [source: "pointer" | "keyboard"];
toolbarExecute: [source: "pointer" | "keyboard"];
executePointerDown: [];
cancel: [];
previewChanges: [];
@@ -56,7 +58,7 @@ const emit = defineEmits<{
formatSql: [];
compressSql: [];
toggleSqlKeywordCase: [];
saveSql: [];
saveSql: [tabId: string];
openSql: [];
importResultArchive: [];
pasteSqlInCondition: [];
@@ -80,12 +82,73 @@ const settingsStore = useSettingsStore();
const { databaseOptions, loadingDatabaseOptions, loadDatabaseOptions, catalogOptions, loadingCatalogOptions, loadCatalogOptions, catalogDatabaseOptions, loadingCatalogDatabaseOptions, loadCatalogDatabaseOptions } = useDatabaseOptions();
const { loadSchemaOptions, getSchemaOptionsForDb, isLoadingSchemas, isSchemaAware } = useSchemaOptions();
const toolbarRootRef = ref<HTMLElement | null>(null);
const toolbarTier = 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 toolbarResizeObserver: ResizeObserver | undefined;
function measureToolbarTier() {
const element = toolbarRootRef.value;
if (!element) {
return;
}
const next = resolveNextEditorToolbarTier({
tier: toolbarTier.value,
availableWidth: element.clientWidth,
contentWidth: element.scrollWidth,
condensedAtWidth: condensedAtWidth.value,
});
if (next !== toolbarTier.value) {
if (next > toolbarTier.value) {
condensedAtWidth.value = element.clientWidth;
}
toolbarTier.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(toolbarTier, () => {
void nextTick(measureToolbarTier);
});
// The visible control set also changes with connection type and transaction
// state; re-measure when those do.
watch(
() => [props.activeConnection?.id, props.activeConnection?.db_type, props.txnSessionId, props.activeTab.isExecuting, props.activeTab.isExplaining] as const,
() => {
void nextTick(measureToolbarTier);
},
);
watch(
toolbarRootRef,
(element) => {
toolbarResizeObserver?.disconnect();
toolbarResizeObserver = undefined;
if (element && typeof ResizeObserver !== "undefined") {
toolbarResizeObserver = new ResizeObserver(measureToolbarTier);
toolbarResizeObserver.observe(element);
}
void nextTick(measureToolbarTier);
},
{ flush: "post" },
);
onUnmounted(() => {
toolbarResizeObserver?.disconnect();
toolbarResizeObserver = undefined;
});
const activeCatalogs = computed(() => {
const connection = props.activeConnection;
return connection ? (catalogOptions.value[connection.id] ?? []) : [];
});
const activeCatalogNames = computed(() => activeCatalogs.value.map((catalog) => catalog.name));
const showCatalogSelector = computed(() => connectionIsDorisFamilyCatalogCapable(props.activeConnection) && queryCatalogSelectorVisible(activeCatalogs.value));
const catalogSelectorAvailable = computed(() => connectionIsDorisFamilyCatalogCapable(props.activeConnection) && queryCatalogSelectorVisible(activeCatalogs.value));
const showCatalogSelector = computed(() => catalogSelectorAvailable.value && toolbarTier.value < 3);
const activeCatalogValue = computed(() => selectedQueryCatalogName(activeCatalogs.value, props.activeTab.catalog));
const activeCatalogDatabaseKey = computed(() => (props.activeConnection && props.activeTab.catalog ? catalogDatabaseOptionsKey(props.activeConnection.id, props.activeTab.catalog) : ""));
const activeDatabaseOptions = computed(() => {
@@ -205,10 +268,11 @@ const canMultiExecute = computed(() => {
return !!props.executableSql.trim();
});
const showSchemaSelector = computed(() => {
const schemaSelectorAvailable = computed(() => {
const connection = props.activeConnection;
return connection && isSchemaAware(connection.id) && (props.activeTab.database || isSingleDb.value || hasDefaultDatabaseOption.value);
});
const showSchemaSelector = computed(() => schemaSelectorAvailable.value && toolbarTier.value < 3);
const activeSchemaOptions = computed(() => {
const connection = props.activeConnection;
@@ -252,6 +316,24 @@ watchEffect(() => {
});
const isActiveDatabaseDefault = computed(() => isDefaultDatabase(props.activeConnection, activeDatabaseValue.value));
// Narrow panes (multi-group splits) condense the toolbar by measured width
// instead of overflowing or scrolling. See resolveEditorToolbarTier for the
// tier contract.
const showOverflowMenu = computed(() => toolbarTier.value >= 1);
const showFormatButton = computed(() => toolbarTier.value < 2);
const showExplainAnalyzeToggle = computed(() => toolbarTier.value < 3);
const showCompressButton = computed(() => toolbarTier.value < 1);
const showKeywordCaseButton = computed(() => toolbarTier.value < 1);
const showSemanticDiagnosticsButton = computed(() => supportsSqlSemanticDiagnosticsToggle.value && toolbarTier.value < 1);
const showPreviewButton = computed(() => previewButtonVisible.value && toolbarTier.value < 1);
const showInsertValueHintsButton = computed(() => supportsInsertValueHintsToggle.value && toolbarTier.value < 1);
const showOpenSqlButton = computed(() => toolbarTier.value < 1);
const showImportArchiveButton = computed(() => toolbarTier.value < 1);
const showPasteSqlButton = computed(() => toolbarTier.value < 1);
const showMultiExecuteButton = computed(() => toolbarTier.value < 1);
const showDatabaseHelperButtons = computed(() => toolbarTier.value < 2);
const toolbarStyle = computed(() => {
const color = props.activeConnection?.color;
if (!color) return undefined;
@@ -283,7 +365,7 @@ function onExecuteClick(event: MouseEvent) {
emit("cancel");
return;
}
emit("execute", event.detail > 0 ? "pointer" : "keyboard");
emit("toolbarExecute", event.detail > 0 ? "pointer" : "keyboard");
}
async function changeCatalog(selectedCatalog: string) {
@@ -301,7 +383,7 @@ async function changeCatalog(selectedCatalog: string) {
</script>
<template>
<div class="app-editor-toolbar h-9 shrink-0 border-b bg-background/80 px-3 flex items-center gap-1 text-xs text-muted-foreground relative z-10" :style="toolbarStyle">
<div ref="toolbarRootRef" class="app-editor-toolbar h-9 shrink-0 border-b bg-background/80 px-3 flex items-center gap-1 text-xs text-muted-foreground relative z-10 overflow-hidden" :style="toolbarStyle">
<div class="flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger as-child>
@@ -321,7 +403,7 @@ async function changeCatalog(selectedCatalog: string) {
</TooltipTrigger>
<TooltipContent>{{ activeTab.isExecuting ? t("toolbar.stopQuery") : executeShortcutTooltip }}</TooltipContent>
</Tooltip>
<Tooltip v-if="previewButtonVisible">
<Tooltip v-if="showPreviewButton">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-sky-600 hover:bg-sky-500/10 hover:text-sky-700" :disabled="activeTab.isExecuting || activeTab.isCancelling" :aria-label="t('editor.previewChanges')" @mousedown.prevent @click="emit('previewChanges')">
<Eye class="h-3.5 w-3.5" />
@@ -346,7 +428,7 @@ async function changeCatalog(selectedCatalog: string) {
<TooltipContent>{{ activeTab.isExplaining ? t("toolbar.stopExplain") : t("toolbar.explainPlan") }}</TooltipContent>
</Tooltip>
<!-- Autotrace (DM) / EXPLAIN ANALYZE (Postgres) / actual plan (SQL Server) toggle -->
<Tooltip v-if="supportsExplainAnalyze">
<Tooltip v-if="showExplainAnalyzeToggle">
<TooltipTrigger as-child>
<Button
variant="ghost"
@@ -363,7 +445,7 @@ async function changeCatalog(selectedCatalog: string) {
</TooltipTrigger>
<TooltipContent>{{ explainAnalyzeTooltip }}</TooltipContent>
</Tooltip>
<Tooltip>
<Tooltip v-if="showFormatButton">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-amber-600 hover:bg-amber-500/10 hover:text-amber-700 dark:text-amber-300 dark:hover:text-amber-200" :disabled="activeTab.isExecuting || activeTab.isExplaining || !activeTab.sql.trim()" @click="emit('formatSql')">
<AlignLeft class="h-3.5 w-3.5" />
@@ -371,7 +453,7 @@ async function changeCatalog(selectedCatalog: string) {
</TooltipTrigger>
<TooltipContent>{{ t("toolbar.formatSql") }}</TooltipContent>
</Tooltip>
<Tooltip>
<Tooltip v-if="showCompressButton">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-amber-600 hover:bg-amber-500/10 hover:text-amber-700 dark:text-amber-300 dark:hover:text-amber-200" :disabled="activeTab.isExecuting || activeTab.isExplaining || !activeTab.sql.trim()" @click="emit('compressSql')">
<Minimize2 class="h-3.5 w-3.5" />
@@ -379,7 +461,7 @@ async function changeCatalog(selectedCatalog: string) {
</TooltipTrigger>
<TooltipContent>{{ t("toolbar.compressSql") }}</TooltipContent>
</Tooltip>
<Tooltip>
<Tooltip v-if="showKeywordCaseButton">
<TooltipTrigger as-child>
<Button
variant="ghost"
@@ -394,7 +476,7 @@ async function changeCatalog(selectedCatalog: string) {
</TooltipTrigger>
<TooltipContent>{{ keywordCaseToggleTooltip }}</TooltipContent>
</Tooltip>
<Tooltip v-if="supportsSqlSemanticDiagnosticsToggle">
<Tooltip v-if="showSemanticDiagnosticsButton">
<TooltipTrigger as-child>
<Button
variant="ghost"
@@ -409,7 +491,7 @@ async function changeCatalog(selectedCatalog: string) {
</TooltipTrigger>
<TooltipContent>{{ sqlSemanticDiagnosticsToggleTooltip }}</TooltipContent>
</Tooltip>
<Tooltip v-if="supportsInsertValueHintsToggle">
<Tooltip v-if="showInsertValueHintsButton">
<TooltipTrigger as-child>
<Button
variant="ghost"
@@ -441,13 +523,13 @@ async function changeCatalog(selectedCatalog: string) {
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-blue-600 hover:bg-blue-500/10 hover:text-blue-700 dark:text-blue-300 dark:hover:text-blue-200" :disabled="!canSaveSql" @click="emit('saveSql')">
<Button variant="ghost" size="icon" class="h-6 w-6 text-blue-600 hover:bg-blue-500/10 hover:text-blue-700 dark:text-blue-300 dark:hover:text-blue-200" :disabled="!canSaveSql" @click="emit('saveSql', props.activeTab.id)">
<Save class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ saveTooltip }}</TooltipContent>
</Tooltip>
<Tooltip>
<Tooltip v-if="showOpenSqlButton">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-sky-600 hover:bg-sky-500/10 hover:text-sky-700 dark:text-sky-300 dark:hover:text-sky-200" @click="emit('openSql')">
<FolderOpen class="h-3.5 w-3.5" />
@@ -455,7 +537,7 @@ async function changeCatalog(selectedCatalog: string) {
</TooltipTrigger>
<TooltipContent>{{ t("toolbar.openSql") }}</TooltipContent>
</Tooltip>
<Tooltip>
<Tooltip v-if="showImportArchiveButton">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-cyan-600 hover:bg-cyan-500/10 hover:text-cyan-700 dark:text-cyan-300 dark:hover:text-cyan-200" @click="emit('importResultArchive')">
<Download class="h-3.5 w-3.5" />
@@ -463,7 +545,7 @@ async function changeCatalog(selectedCatalog: string) {
</TooltipTrigger>
<TooltipContent>{{ t("tabs.importResultArchive") }}</TooltipContent>
</Tooltip>
<Tooltip v-if="supportsExPaste">
<Tooltip v-if="showPasteSqlButton">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-teal-600 hover:bg-teal-500/10 hover:text-teal-700 dark:text-teal-300 dark:hover:text-teal-200" @click="emit('pasteSqlInCondition')">
<ClipboardPaste class="h-3.5 w-3.5" />
@@ -471,7 +553,7 @@ async function changeCatalog(selectedCatalog: string) {
</TooltipTrigger>
<TooltipContent>{{ t("toolbar.exPasteSqlInCondition") }}</TooltipContent>
</Tooltip>
<Tooltip>
<Tooltip v-if="showMultiExecuteButton">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-primary hover:bg-primary/10" :disabled="!canMultiExecute" :aria-label="t('toolbar.multiDbExecute')" @click="emit('multiExecute')">
<CirclePlay class="h-3.5 w-3.5" />
@@ -479,6 +561,68 @@ async function changeCatalog(selectedCatalog: string) {
</TooltipTrigger>
<TooltipContent>{{ t("toolbar.multiDbExecute") }}</TooltipContent>
</Tooltip>
<DropdownMenu v-if="showOverflowMenu">
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-muted-foreground hover:text-foreground" :aria-label="t('toolbar.moreActions')" :title="t('toolbar.moreActions')">
<MoreHorizontal class="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="w-56">
<DropdownMenuItem :disabled="activeTab.isExecuting || activeTab.isExplaining || !activeTab.sql.trim()" @select="emit('compressSql')">
<Minimize2 class="h-3.5 w-3.5" />
{{ t("toolbar.compressSql") }}
</DropdownMenuItem>
<DropdownMenuItem @select="emit('toggleSqlKeywordCase')">
{{ keywordCaseToggleTooltip }}
</DropdownMenuItem>
<DropdownMenuCheckboxItem v-if="supportsSqlSemanticDiagnosticsToggle" :model-value="sqlSemanticDiagnosticsEnabled" @select.prevent @update:model-value="toggleSqlSemanticDiagnostics()">
<SpellCheck2 class="h-3.5 w-3.5" />
{{ sqlSemanticDiagnosticsToggleTooltip }}
</DropdownMenuCheckboxItem>
<DropdownMenuItem @select="emit('openSql')">
<FolderOpen class="h-3.5 w-3.5" />
{{ t("toolbar.openSql") }}
</DropdownMenuItem>
<DropdownMenuItem @select="emit('importResultArchive')">
<Download class="h-3.5 w-3.5" />
{{ t("tabs.importResultArchive") }}
</DropdownMenuItem>
<DropdownMenuItem v-if="supportsExPaste" @select="emit('pasteSqlInCondition')">
<ClipboardPaste class="h-3.5 w-3.5" />
{{ t("toolbar.exPasteSqlInCondition") }}
</DropdownMenuItem>
<DropdownMenuItem :disabled="!canMultiExecute" @select="emit('multiExecute')">
<CirclePlay class="h-3.5 w-3.5" />
{{ t("toolbar.multiDbExecute") }}
</DropdownMenuItem>
<DropdownMenuItem v-if="previewButtonVisible" :disabled="activeTab.isExecuting || activeTab.isCancelling" @select="emit('previewChanges')">
<Eye class="h-3.5 w-3.5" />
{{ t("editor.previewChanges") }}
</DropdownMenuItem>
<DropdownMenuCheckboxItem v-if="supportsInsertValueHintsToggle" :model-value="insertValueHintsEnabled" @select.prevent @update:model-value="toggleInsertValueHints">
<BetweenVerticalStart class="h-3.5 w-3.5" />
{{ insertValueHintsToggleTooltip }}
</DropdownMenuCheckboxItem>
<template v-if="toolbarTier >= 2">
<DropdownMenuItem :disabled="activeTab.isExecuting || activeTab.isExplaining || !activeTab.sql.trim()" @select="emit('formatSql')">
<AlignLeft class="h-3.5 w-3.5" />
{{ t("toolbar.formatSql") }}
</DropdownMenuItem>
<DropdownMenuItem v-if="activeDatabaseValue" :disabled="!activeConnection" @select="emit('changeDatabase', '')">
<X class="h-3.5 w-3.5" />
{{ t("editor.clearDatabase") }}
</DropdownMenuItem>
<DropdownMenuItem v-if="activeDatabaseValue && !activeTab.catalog" @select="isActiveDatabaseDefault ? emit('clearDefaultDatabase') : emit('setDefaultDatabase')">
<Check v-if="isActiveDatabaseDefault" class="h-3 w-3" />
{{ isActiveDatabaseDefault ? t("editor.defaultDatabase") : t("editor.setDefaultDatabase") }}
</DropdownMenuItem>
</template>
<DropdownMenuCheckboxItem v-if="toolbarTier >= 3 && supportsExplainAnalyze" :model-value="props.explainMode === 'autotrace'" @select.prevent @update:model-value="emit('update:explainMode', props.explainMode === 'autotrace' ? 'explain' : 'autotrace')">
<span class="font-bold" style="font-size: 9px">A</span>
{{ explainAnalyzeTooltip }}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
<div v-if="supportsTransaction" class="ml-1 flex items-center gap-0.5 border-l border-border/60 pl-1" role="group" :aria-label="transactionTooltip">
<!-- Transaction toggle -->
<Tooltip>
@@ -523,8 +667,8 @@ async function changeCatalog(selectedCatalog: string) {
</div>
</div>
<span class="flex-1 min-w-0" />
<div class="flex items-center gap-2 shrink-0">
<div class="flex items-center gap-1">
<div class="flex min-w-0 items-center gap-2">
<div class="flex min-w-0 items-center gap-1">
<span v-if="activeConnection?.color" class="h-4 w-1 rounded-full shrink-0" :style="{ backgroundColor: activeConnection.color }" />
<ConnectionTreeSelect
:model-value="activeConnectionValue"
@@ -548,7 +692,7 @@ async function changeCatalog(selectedCatalog: string) {
</template>
</ConnectionTreeSelect>
</div>
<div v-if="showCatalogSelector" class="flex items-center gap-1">
<div v-if="showCatalogSelector" class="flex min-w-0 items-center gap-1">
<SearchableSelect
:model-value="activeCatalogValue"
:options="activeCatalogNames"
@@ -622,7 +766,7 @@ async function changeCatalog(selectedCatalog: string) {
</div>
</template>
</SearchableSelect>
<Tooltip v-if="activeDatabaseValue && !isSingleDb">
<Tooltip v-if="showDatabaseHelperButtons && activeDatabaseValue && !isSingleDb">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-muted-foreground hover:text-foreground" @click="emit('changeDatabase', '')">
<X class="h-3.5 w-3.5" />
@@ -630,12 +774,12 @@ async function changeCatalog(selectedCatalog: string) {
</TooltipTrigger>
<TooltipContent>{{ t("editor.clearDatabase") }}</TooltipContent>
</Tooltip>
<Button v-if="activeDatabaseValue && !activeTab.catalog" variant="ghost" size="sm" class="h-6 px-2 text-[11px]" @click="isActiveDatabaseDefault ? emit('clearDefaultDatabase') : emit('setDefaultDatabase')">
<Button v-if="showDatabaseHelperButtons && activeDatabaseValue && !activeTab.catalog" variant="ghost" size="sm" class="h-6 px-2 text-[11px]" @click="isActiveDatabaseDefault ? emit('clearDefaultDatabase') : emit('setDefaultDatabase')">
<Check v-if="isActiveDatabaseDefault" class="h-3 w-3" />
{{ isActiveDatabaseDefault ? t("editor.defaultDatabase") : t("editor.setDefaultDatabase") }}
</Button>
</div>
<div v-if="showSchemaSelector" class="flex items-center gap-1">
<div v-if="showSchemaSelector" class="flex min-w-0 items-center gap-1">
<SearchableSelect
:model-value="activeSchemaValue"
:options="activeSchemaOptions.length ? activeSchemaOptions : activeSchemaValue ? [activeSchemaValue] : []"
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import ContentArea from "./ContentArea.vue";
import { createContentSurfaceEventForwarders } from "@/lib/tabs/contentSurfaceEvents";
import type { ContentAreaSurfaceEmits, ContentAreaSurfaceProps, QueryEditorSurfaceHandle, StatementRange } from "./querySurfaces";
const props = defineProps<ContentAreaSurfaceProps & { autoFocus?: boolean }>();
const emit = defineEmits<ContentAreaSurfaceEmits>();
const surfaceProps = computed<ContentAreaSurfaceProps>(() => ({
activeTab: props.activeTab,
activeConnection: props.activeConnection,
executableSql: props.executableSql,
activeOutputView: props.activeOutputView,
formatSqlRequest: props.formatSqlRequest,
compressSqlRequest: props.compressSqlRequest,
selectedSql: props.selectedSql,
cursorPos: props.cursorPos,
blockDangerousRedisCommands: props.blockDangerousRedisCommands,
}));
const contentEmits = createContentSurfaceEventForwarders(emit);
const bindings = computed(() => ({ ...surfaceProps.value, ...contentEmits }));
const contentAreaRef = ref<InstanceType<typeof ContentArea> | null>(null);
defineExpose<QueryEditorSurfaceHandle>({
focusSearch: () => contentAreaRef.value?.focusSearch() ?? false,
openGoToColumn: () => contentAreaRef.value?.openGoToColumn() ?? false,
refreshData: () => contentAreaRef.value?.refreshData() ?? false,
toggleResultsPane: () => contentAreaRef.value?.toggleResultsPane() ?? false,
refreshQueryEditorCompletionCache: () => contentAreaRef.value?.refreshQueryEditorCompletionCache() ?? false,
handleModRTarget: (target: Element) => contentAreaRef.value?.handleModRTarget(target) ?? false,
requestQueryEditorExecute: () => contentAreaRef.value?.requestQueryEditorExecute() ?? false,
captureQueryEditorExecutionSnapshot: () => contentAreaRef.value?.captureQueryEditorExecutionSnapshot(),
requestQueryEditorExecuteInNewResultTab: () => contentAreaRef.value?.requestQueryEditorExecuteInNewResultTab() ?? false,
requestQueryEditorPreviewChanges: (stackSql?: string) => Promise.resolve(contentAreaRef.value?.requestQueryEditorPreviewChanges(stackSql) ?? false),
shouldBlockQueryEditorExecutionShortcut: (event: KeyboardEvent) => contentAreaRef.value?.shouldBlockQueryEditorExecutionShortcut(event) ?? false,
cancelQueryEditorExecutionViewport: (requestId: number) => contentAreaRef.value?.cancelQueryEditorExecutionViewport(requestId) ?? false,
acceptQueryEditorExecutionViewport: (requestId: number) => contentAreaRef.value?.acceptQueryEditorExecutionViewport(requestId) ?? false,
pasteClipboardAsSqlInCondition: () => contentAreaRef.value?.pasteClipboardAsSqlInCondition() ?? Promise.resolve(false),
applyTableStructureChanges: () => contentAreaRef.value?.applyTableStructureChanges() ?? Promise.resolve(false),
insertRedisCommand: (command: string) => contentAreaRef.value?.insertRedisCommand(command) ?? Promise.resolve(false),
executeRedisCommand: (command: string) => contentAreaRef.value?.executeRedisCommand(command) ?? Promise.resolve(false),
previewStatementRange: (range: StatementRange | null) => contentAreaRef.value?.previewStatementRange(range) ?? false,
focusStatementRange: (range: StatementRange | null) => contentAreaRef.value?.focusStatementRange(range) ?? false,
});
</script>
<template>
<ContentArea ref="contentAreaRef" v-bind="bindings" editor-only :auto-focus="props.autoFocus" />
</template>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { computed, ref, useAttrs } from "vue";
import ContentArea from "./ContentArea.vue";
import { createContentSurfaceEventForwarders } from "@/lib/tabs/contentSurfaceEvents";
import type { ContentAreaSurfaceEmits, ContentAreaSurfaceProps, QueryResultSurfaceHandle, StatementRange } from "./querySurfaces";
defineOptions({ inheritAttrs: false });
const props = defineProps<ContentAreaSurfaceProps>();
const emit = defineEmits<ContentAreaSurfaceEmits>();
const attrs = useAttrs();
const resultClass = computed(() => (typeof attrs.class === "string" ? attrs.class : undefined));
const surfaceProps = computed<ContentAreaSurfaceProps>(() => ({
activeTab: props.activeTab,
activeConnection: props.activeConnection,
executableSql: props.executableSql,
activeOutputView: props.activeOutputView,
formatSqlRequest: props.formatSqlRequest,
compressSqlRequest: props.compressSqlRequest,
selectedSql: props.selectedSql,
cursorPos: props.cursorPos,
blockDangerousRedisCommands: props.blockDangerousRedisCommands,
}));
const contentEmits = createContentSurfaceEventForwarders(emit);
const bindings = computed(() => ({ ...surfaceProps.value, ...contentEmits }));
const contentAreaRef = ref<InstanceType<typeof ContentArea> | null>(null);
defineExpose<QueryResultSurfaceHandle>({
focusSearch: () => contentAreaRef.value?.focusSearch() ?? false,
refreshData: () => contentAreaRef.value?.refreshData() ?? false,
toggleResultsPane: () => contentAreaRef.value?.toggleResultsPane() ?? false,
handleModRTarget: (target: Element) => contentAreaRef.value?.handleModRTarget(target) ?? false,
previewStatementRange: (range: StatementRange | null) => contentAreaRef.value?.previewStatementRange(range) ?? false,
focusStatementRange: (range: StatementRange | null) => contentAreaRef.value?.focusStatementRange(range) ?? false,
});
</script>
<template>
<div class="min-h-0 overflow-hidden" :class="resultClass">
<ContentArea ref="contentAreaRef" v-bind="bindings" result-only class="h-full" />
</div>
</template>
@@ -0,0 +1,256 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, useAttrs } from "vue";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import "./sqlEditorWorkspace.css";
import { useQueryStore } from "@/stores/queryStore";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import EditorGroup from "./EditorGroup.vue";
import QueryResultSurface from "./QueryResultSurface.vue";
import { createContentSurfaceEventForwarders } from "@/lib/tabs/contentSurfaceEvents";
import type { ContentAreaSurfaceEmits, ContentAreaSurfaceProps, StatementRange } from "./querySurfaces";
import type { QueryTab } from "@/types/database";
defineOptions({ inheritAttrs: false });
const props = defineProps<
ContentAreaSurfaceProps & {
tabBarWidth?: number;
tabBarCollapsed?: boolean;
canDetachTabs?: boolean;
detachedDropTarget?: boolean;
}
>();
const emit = defineEmits<
ContentAreaSurfaceEmits & {
"locate-tab": [tab: QueryTab];
"toggle-zen-mode": [];
"start-resize": [event: MouseEvent];
"toggle-collapse": [];
"detach-tab": [tab: QueryTab];
}
>();
const attrs = useAttrs();
const workspaceClass = computed(() => (typeof attrs.class === "string" ? attrs.class : undefined));
const surfaceProps = computed<ContentAreaSurfaceProps>(() => ({
activeTab: props.activeTab,
activeConnection: props.activeConnection,
executableSql: props.executableSql,
activeOutputView: props.activeOutputView,
formatSqlRequest: props.formatSqlRequest,
compressSqlRequest: props.compressSqlRequest,
selectedSql: props.selectedSql,
cursorPos: props.cursorPos,
blockDangerousRedisCommands: props.blockDangerousRedisCommands,
zenMode: props.zenMode,
}));
const contentEmits = createContentSurfaceEventForwarders(emit);
const editorGroupBindings = computed(() => ({ ...surfaceProps.value, ...contentEmits }));
const resultSurfaceBindings = computed(() => ({ ...surfaceProps.value, ...contentEmits }));
defineExpose({
focusSearch: (target: Element | null = null) => {
const element = commandTargetElement(target);
// The shared result pane (and its Teleported cell-detail dialog) lives
// outside the group DOM, so route its search before the group fallback.
// A data-mode cell-detail dialog is portaled to body too but belongs to
// the group's grid — indistinguishable in the DOM, hence the gate.
if (element?.closest("[data-shared-result-surface]") || (showSharedResult.value && element?.closest("[data-cell-detail-editor-root]"))) {
return resultSurfaceRef.value?.focusSearch() ?? false;
}
const group = groupForElement(element) ?? activeEditorGroup();
return group?.focusSearch() ?? false;
},
openGoToColumn: () => activeEditorGroup()?.openGoToColumn() ?? false,
refreshData: (target: Element | null = null) => {
const element = commandTargetElement(target);
if (element?.closest("[data-shared-result-surface]")) {
return resultSurfaceRef.value?.refreshData() ?? false;
}
const group = groupForElement(element) ?? activeEditorGroup();
return group?.refreshData() ?? resultSurfaceRef.value?.refreshData() ?? false;
},
toggleResultsPane: () => {
if (!showSharedResult.value) {
return activeEditorGroup()?.toggleResultsPane() ?? false;
}
showResultPane.value = !showResultPane.value;
return true;
},
refreshQueryEditorCompletionCache: () => activeEditorGroup()?.refreshQueryEditorCompletionCache() ?? false,
handleModRTarget: (target: Element) => {
// DataGrid, Elasticsearch JSON, and the cell-detail editor (Teleported
// outside the grid DOM) belong to the shared result surface while a query
// tab is active. Data-mode grids live inside a group's ContentArea and
// their cell-detail dialogs are portaled to body just the same, so route
// those to the group instead of the (unmounted) shared result surface.
if (showSharedResult.value && target.closest("[data-grid-root], [data-elasticsearch-json-response-root], [data-cell-detail-editor-root]")) {
return resultSurfaceRef.value?.handleModRTarget(target) ?? false;
}
const group = groupForElement(target) ?? activeEditorGroup();
return group?.handleModRTarget(target) ?? false;
},
requestQueryEditorExecute: () => activeEditorGroup()?.requestQueryEditorExecute() ?? false,
captureQueryEditorExecutionSnapshot: () => activeEditorGroup()?.captureQueryEditorExecutionSnapshot(),
requestQueryEditorExecuteInNewResultTab: () => activeEditorGroup()?.requestQueryEditorExecuteInNewResultTab() ?? false,
requestQueryEditorPreviewChanges: (stackSql?: string) => activeEditorGroup()?.requestQueryEditorPreviewChanges(stackSql) ?? false,
shouldBlockQueryEditorExecutionShortcut: (event: KeyboardEvent) => activeEditorGroup()?.shouldBlockQueryEditorExecutionShortcut(event) ?? false,
cancelQueryEditorExecutionViewport: (requestId: number) => activeEditorGroup()?.cancelQueryEditorExecutionViewport(requestId) ?? false,
acceptQueryEditorExecutionViewport: (requestId: number) => activeEditorGroup()?.acceptQueryEditorExecutionViewport(requestId) ?? false,
pasteClipboardAsSqlInCondition: () => activeEditorGroup()?.pasteClipboardAsSqlInCondition() ?? Promise.resolve(false),
applyTableStructureChanges: () => {
const group = groupForElement(commandTargetElement(null));
return group?.applyTableStructureChanges() ?? activeEditorGroup()?.applyTableStructureChanges() ?? Promise.resolve(false);
},
insertRedisCommand: (command: string) => (groupForElement(commandTargetElement(null)) ?? activeEditorGroup())?.insertRedisCommand(command) ?? Promise.resolve(false),
executeRedisCommand: (command: string) => (groupForElement(commandTargetElement(null)) ?? activeEditorGroup())?.executeRedisCommand(command) ?? Promise.resolve(false),
});
const queryStore = useQueryStore();
const activeTab = computed(() => queryStore.tabs.find((tab) => tab.id === queryStore.activeTabId));
const showSharedResult = computed(() => activeTab.value?.mode === "query");
const SHARED_RESULT_PANE_MIN_SIZE = 12;
const SHARED_RESULT_PANE_MAX_SIZE = 80;
const SHARED_RESULT_PANE_DEFAULT_SIZE = 32;
const SHARED_RESULT_PANE_STORAGE_KEY = "dbx-shared-results-pane-size";
const storedResultPaneSize = Number(safeLocalStorageGet(SHARED_RESULT_PANE_STORAGE_KEY));
const resultPaneSize = ref(Number.isFinite(storedResultPaneSize) && storedResultPaneSize >= SHARED_RESULT_PANE_MIN_SIZE && storedResultPaneSize <= SHARED_RESULT_PANE_MAX_SIZE ? storedResultPaneSize : SHARED_RESULT_PANE_DEFAULT_SIZE);
const showResultPane = ref(true);
// The result pane stays mounted and animates its size between the stored
// split and 0, so collapsing/expanding glides instead of jumping: the stock
// splitpanes pane transition is re-enabled for this workspace in
// sqlEditorWorkspace.css (globals.css kills it for horizontal splitpanes).
const resultPaneTargetSize = computed(() => (showSharedResult.value && showResultPane.value ? resultPaneSize.value : 0));
const editorPaneSize = computed(() => 100 - resultPaneTargetSize.value);
// Entrance choreography is hydration-gated: groups present at first render
// never animate (no load choreography); only groups created later — by a
// split — materialize from their owner's side of the divider.
const hydrated = ref(false);
const initialGroupIds = new Set(queryStore.groups.map((group) => group.id));
onMounted(() => {
void nextTick(() => {
hydrated.value = true;
});
});
function paneEnterClass(groupId: string): string | undefined {
if (!hydrated.value || initialGroupIds.has(groupId)) {
return undefined;
}
return queryStore.orientation === "horizontal" ? "workspace-pane-enter workspace-pane-enter--from-top" : "workspace-pane-enter workspace-pane-enter--from-left";
}
function onSharedResultResized(payload: { panes: { size: number }[] }) {
const resultPane = payload.panes[1];
if (resultPane?.size != null && resultPane.size >= SHARED_RESULT_PANE_MIN_SIZE && resultPane.size <= SHARED_RESULT_PANE_MAX_SIZE) {
resultPaneSize.value = resultPane.size;
safeLocalStorageSet(SHARED_RESULT_PANE_STORAGE_KEY, String(resultPane.size));
}
}
const groupRefs = new Map<string, InstanceType<typeof EditorGroup>>();
const resultSurfaceRef = ref<InstanceType<typeof QueryResultSurface> | null>(null);
function setGroupRef(groupId: string, el: unknown) {
if (el) {
groupRefs.set(groupId, el as InstanceType<typeof EditorGroup>);
} else {
groupRefs.delete(groupId);
}
}
function activeEditorGroup() {
const group = queryStore.groups.find((item) => item.id === queryStore.focusedGroupId) ?? queryStore.groups[0];
return group ? (groupRefs.get(group.id) ?? null) : null;
}
function groupForElement(element: Element | null): InstanceType<typeof EditorGroup> | null {
const groupElement = element?.closest<HTMLElement>("[data-group-id]");
const groupId = groupElement?.dataset.groupId;
return groupId ? (groupRefs.get(groupId) ?? null) : null;
}
/**
* Resolves the command target element for a global keyboard command: the
* caller-supplied event target when available, otherwise the live focus.
*/
function commandTargetElement(target: Element | null): Element | null {
if (target) {
return target;
}
return document.activeElement instanceof Element ? document.activeElement : null;
}
function handlePreviewStatement(tabId: string, range: StatementRange | null): boolean {
for (const group of groupRefs.values()) {
if (group.previewStatementRange(tabId, range)) {
return true;
}
}
return false;
}
function handleFocusStatement(tabId: string, range: StatementRange | null): boolean {
for (const group of groupRefs.values()) {
if (group.focusStatementRange(tabId, range)) {
return true;
}
}
return false;
}
</script>
<template>
<div class="sql-editor-workspace flex h-full min-h-0 flex-1 flex-col overflow-hidden" :class="workspaceClass">
<Splitpanes horizontal class="sql-editor-workspace-split flex-1 min-h-0" :class="{ 'result-pane-collapsed': resultPaneTargetSize === 0 }" @resized="onSharedResultResized">
<Pane class="min-h-0 min-w-0" :size="editorPaneSize" :min-size="100 - SHARED_RESULT_PANE_MAX_SIZE">
<Splitpanes
:horizontal="queryStore.orientation === 'horizontal'"
class="sql-editor-groups h-full min-h-0"
@resized="
(event: { panes: Array<{ size: number }> }) => {
queryStore.sizes = event.panes.map((pane) => pane.size);
}
"
>
<Pane v-for="group in queryStore.groups" :key="group.id" :size="queryStore.sizes[queryStore.groups.indexOf(group)] ?? undefined" :min-size="10" class="min-h-0 min-w-0" :class="paneEnterClass(group.id)">
<EditorGroup
:ref="(el: unknown) => setGroupRef(group.id, el)"
:group-id="group.id"
:tab-ids="group.tabIds"
:active-tab-id="group.activeTabId"
:tab-bar-width="tabBarWidth"
:tab-bar-collapsed="tabBarCollapsed"
:can-detach-tabs="canDetachTabs"
:detached-drop-target="detachedDropTarget"
class="h-full"
v-bind="editorGroupBindings"
@focus-group="queryStore.focusGroup($event)"
@activate-tab="queryStore.activateTabInGroup(group.id, $event)"
@locate-tab="emit('locate-tab', $event)"
@toggle-zen-mode="emit('toggle-zen-mode')"
@start-resize="emit('start-resize', $event)"
@toggle-collapse="emit('toggle-collapse')"
@detach-tab="emit('detach-tab', $event)"
/>
</Pane>
</Splitpanes>
</Pane>
<Pane class="min-h-0" :size="resultPaneTargetSize" :min-size="resultPaneTargetSize > 0 ? SHARED_RESULT_PANE_MIN_SIZE : 0" :max-size="SHARED_RESULT_PANE_MAX_SIZE">
<Transition name="result-surface">
<div v-if="showSharedResult && showResultPane" data-shared-result-surface class="h-full min-h-0 overflow-hidden">
<QueryResultSurface
ref="resultSurfaceRef"
v-bind="resultSurfaceBindings"
class="h-full"
@preview-statement="
(tabId: string, range: { from: number; to: number } | null) => {
handlePreviewStatement(tabId, range);
}
"
@focus-statement="
(tabId: string, range: { from: number; to: number } | null) => {
handleFocusStatement(tabId, range);
}
"
/>
</div>
</Transition>
</Pane>
</Splitpanes>
</div>
</template>
+43 -267
View File
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const tabBarSource = readFileSync(new URL("../AppTabBar.vue", import.meta.url), "utf8");
const groupTabBarSource = readFileSync(new URL("../EditorGroupTabBar.vue", import.meta.url), "utf8");
function sourceBetween(start: string, end: string): string {
const startIndex = tabBarSource.indexOf(start);
@@ -11,6 +12,27 @@ function sourceBetween(start: string, end: string): string {
return tabBarSource.slice(startIndex, endIndex);
}
describe("AppTabBar single production path", () => {
it("no longer renders the regular or pinned tab strip", () => {
expect(tabBarSource).not.toContain("hideRegularTabs");
expect(tabBarSource).not.toContain("regularTabs");
expect(tabBarSource).not.toContain("fixedTabsContainerRef");
expect(tabBarSource).not.toContain("useTabDrag");
expect(tabBarSource).not.toContain("createRenameDuplicateTabItems");
expect(groupTabBarSource).toContain("createRenameDuplicateTabItems");
});
it("keeps special pages and the close-confirm dialog as its only surfaces", () => {
expect(tabBarSource).toContain("getSpecialRegularTabMenuItems('settings')");
expect(tabBarSource).toContain("getSpecialRegularTabMenuItems('driverStore')");
expect(tabBarSource).toContain(':open="queryStore.showCloseConfirm"');
});
it("scopes the close-other shortcut to the active tab's owner group", () => {
expect(tabBarSource).toContain("closeOtherTabsInGroup(ownerGroup.id, activeTabId)");
});
});
describe("AppTabBar close confirmation layout", () => {
it("allows long unbroken tab titles to shrink and wrap inside the dialog", () => {
expect(tabBarSource).toMatch(/<DialogContent class="[^"]*\bmin-w-0\b[^"]*\bsm:max-w-md\b/);
@@ -25,284 +47,38 @@ describe("AppTabBar close confirmation layout", () => {
expect(tabBarSource).toContain('@click="handleSaveAndClose"');
expect(tabBarSource).toContain('@click="handleCancelClose"');
});
});
describe("AppTabBar HBase presentation", () => {
it("uses the table icon in regular, pinned, and overflow tab surfaces", () => {
expect(tabBarSource).toContain('if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "redis" || tab.mode === "hbase") return Table2;');
expect(tabBarSource.match(/tab\.mode === 'hbase'/g)).toHaveLength(2);
expect(tabBarSource).toContain('tab.mode === "hbase" || tab.mode === "structure"');
});
});
describe("AppTabBar object browser presentation", () => {
it("uses matching icons and colors for object and database browser tabs", () => {
expect(tabBarSource).toContain('if (tab.mode === "databases" || tab.mode === "objects") return "text-amber-500 dark:text-amber-400";');
expect(tabBarSource).toContain('if (tab.mode === "databases") return Database;');
expect(tabBarSource).toContain('if (tab.mode === "objects") return TableProperties;');
expect(tabBarSource.match(/tab\.mode === 'databases'/g)).toHaveLength(2);
expect(tabBarSource.match(/:class="tabIconClass\(tab\)"/g)).toHaveLength(2);
expect(tabBarSource.match(/tabMenuIcon\(tab\).*tabIconClass\(tab\)/g)).toHaveLength(2);
});
});
describe("AppTabBar locate-in-sidebar action", () => {
it("emits the exact right-clicked tab only when it has a sidebar target", () => {
const menu = sourceBetween("function getTabMenuItems", "function handleSaveAndClose");
expect(tabBarSource).toContain('"locate-tab": [tab: QueryTab];');
expect(menu).toMatch(/label: t\("sidebar\.locateActiveTab"\),\s*action: \(\) => emit\("locate-tab", tab\),\s*icon: Crosshair,\s*visible: !!activeTabSidebarTarget\(tab\)/);
expect(menu).not.toContain("activateTab(tab.id)");
});
it("leaves settings, driver, and existing regular-tab menu actions unchanged", () => {
const specialMenu = sourceBetween("function getSpecialRegularTabMenuItems", "function getTabMenuItems");
const regularMenu = sourceBetween("function getTabMenuItems", "function handleSaveAndClose");
expect(specialMenu).not.toContain('t("sidebar.locateActiveTab")');
expect(regularMenu).toContain('label: t("contextMenu.copyName")');
expect(regularMenu).toContain("action: () => queryStore.togglePinnedTab(tab.id)");
expect(regularMenu).toContain("action: () => queryStore.closeTab(tab.id)");
});
});
describe("AppTabBar Zen mode interaction", () => {
it("switches Zen mode for data tabs while preserving query-tab renaming", () => {
const handler = sourceBetween("function handleTabDoubleClick", "function handleTabMouseDown");
expect(tabBarSource).toContain('"toggle-zen-mode": [];');
expect(handler).toContain('if (tab.mode === "data") {');
expect(handler).toContain('emit("toggle-zen-mode");');
expect(handler).toContain("startRenameTab(tab);");
expect(handler).toContain("event.target instanceof Element && event.target.closest(\"button, input, [role='button']\")");
expect(tabBarSource.match(/@dblclick="handleTabDoubleClick\(tab, \$event\)"/g)).toHaveLength(2);
});
});
describe("AppTabBar right-side close action", () => {
it("places the action after close-other and disables it when the target has no tabs to its right", () => {
expect(tabBarSource).toContain('label: t("contextMenu.closeRightTabs")');
expect(tabBarSource).toContain("action: () => closeTabsToRightFromTab(tab)");
expect(tabBarSource).toContain("disabled: !hasTabsToRight(tab)");
const closeOtherPositions = [...tabBarSource.matchAll(/label: closeOtherLabel,/g)].map((match) => match.index);
const closeRightPositions = [...tabBarSource.matchAll(/label: t\("contextMenu\.closeRightTabs"\),/g)].map((match) => match.index);
const closeAllPositions = [...tabBarSource.matchAll(/label: closeAllLabel,/g)].map((match) => match.index);
expect(closeOtherPositions).toHaveLength(2);
expect(closeRightPositions).toHaveLength(2);
expect(closeAllPositions).toHaveLength(2);
closeRightPositions.forEach((position, index) => {
expect(position).toBeGreaterThan(closeOtherPositions[index]);
expect(position).toBeLessThan(closeAllPositions[index]);
});
});
it("waits for query tab confirmation before closing special surfaces", () => {
expect(tabBarSource).toMatch(/queryStore\.closeTabsByIds\(tabsToClose, finalActiveTabId, \(\) => \{[\s\S]*closeSpecialRegularSurfaces\(\);/);
expect(tabBarSource).toContain("if (shouldActivateTarget) activateTab(tab.id)");
});
it("reactivates settings after closing an active driver store to its right", () => {
expect(tabBarSource).toContain("const shouldActivateSettings = !!props.driverStoreActive");
expect(tabBarSource).toMatch(/emit\("close-driver-store"\);\s*if \(shouldActivateSettings\) emit\("activate-settings-page"\);/);
});
});
describe("AppTabBar left-side close action", () => {
it("places the action after close-other, before close-right, and disables it when the target has no tabs to its left", () => {
expect(tabBarSource).toContain('label: t("contextMenu.closeLeftTabs")');
expect(tabBarSource).toContain("action: () => closeTabsToLeftFromTab(tab)");
expect(tabBarSource).toContain("disabled: !hasTabsToLeft(tab)");
const closeOtherPositions = [...tabBarSource.matchAll(/label: closeOtherLabel,/g)].map((match) => match.index);
const closeLeftPositions = [...tabBarSource.matchAll(/label: t\("contextMenu\.closeLeftTabs"\),/g)].map((match) => match.index);
const closeRightPositions = [...tabBarSource.matchAll(/label: t\("contextMenu\.closeRightTabs"\),/g)].map((match) => match.index);
expect(closeOtherPositions).toHaveLength(2);
// The special-surface menu has no left-side action: settings and the
// driver store sit at the rightmost end of the tab bar, so nothing can be
// to the left of them. Only the regular/pinned tab menu gets the item.
expect(closeLeftPositions).toHaveLength(1);
expect(closeRightPositions).toHaveLength(2);
const closeLeftPosition = closeLeftPositions[0];
expect(closeLeftPosition).toBeGreaterThan(closeOtherPositions[1]);
expect(closeLeftPosition).toBeLessThan(closeRightPositions[1]);
it("shows the dirty tab list using the shared tab title presentation", () => {
const listBlock = sourceBetween('v-for="tab in closeConfirmDirtyTabs"', "</PopoverContent>");
expect(listBlock).toContain("tabDisplayTitle(tab, t)");
});
});
describe("AppTabBar special page selection", () => {
it("shows the active settings or driver-manager tab with the same ring used by regular tabs", () => {
expect(tabBarSource).toContain("function specialTabActiveStyle(active: boolean | undefined)");
expect(tabBarSource).toContain('return isClassicLayout.value ? { boxShadow: "inset 0 -2px 0 var(--ring)" } : { borderColor: "var(--ring)" };');
expect(tabBarSource).toContain(':style="specialTabActiveStyle(settingsPageActive)"');
expect(tabBarSource).toContain(':style="specialTabActiveStyle(driverStoreActive)"');
it("renders settings and driver store as app-level pills outside the editor groups", () => {
expect(tabBarSource).toContain("data-settings-page-tab");
expect(tabBarSource).toContain("data-driver-store-tab");
expect(tabBarSource).toContain("@click=\"emit('activate-settings-page')\"");
expect(tabBarSource).toContain("@click=\"emit('activate-driver-store')\"");
expect(tabBarSource).toContain('t("toolbar.driverManager")');
});
it("does not leave a query group highlighted behind an active special page", () => {
const groupActive = sourceBetween("function isTabGroupActive", "function toggleTabGroup");
expect(groupActive).toContain("if (props.driverStoreActive || props.settingsPageActive) return false;");
});
it("restores the active special page after closing the group containing the retained query tab", () => {
const restoreSurface = sourceBetween("function restoreSpecialRegularSurface", "function closeSpecialRegularSurfaces");
const closeGroup = sourceBetween("function closeTabGroup", "function tabsToRightInGroup");
expect(restoreSurface).toContain('if (surface === "settings" && props.settingsPageOpen) emit("activate-settings-page")');
expect(restoreSurface).toContain('if (surface === "driverStore" && props.driverStoreOpen) emit("activate-driver-store")');
expect(restoreSurface).toContain("const surface = pendingGroupCloseSpecialSurface.value;");
expect(restoreSurface).toContain("pendingGroupCloseSpecialSurface.value = null;");
expect(closeGroup).toContain('props.settingsPageActive ? "settings" : props.driverStoreActive ? "driverStore" : null');
expect(closeGroup).toContain("pendingGroupCloseSpecialSurface.value = activeSpecialSurface;");
expect(closeGroup).toContain("queryStore.closeTabsByIds(tabsToClose, finalActiveTabId, finishSpecialSurfacePreservingGroupClose)");
expect(closeGroup).toContain("restoreSpecialRegularSurface(activeSpecialSurface);");
});
it("keeps and restores the special page while dirty grouped tabs are confirmed or cancelled", () => {
const activeTabWatch = sourceBetween("watch(\n () => queryStore.activeTabId", "watch(\n () => props.driverStoreActive");
const dismissClose = sourceBetween("function dismissCloseConfirm", "function handleCancelClose");
const cancelClose = sourceBetween("function handleCancelClose", "const tabsContainerRef");
expect(activeTabWatch).toContain("restoreSpecialRegularSurface(pendingGroupCloseSpecialSurface.value);");
expect(dismissClose).toContain("pendingGroupCloseSpecialSurface.value = null;");
expect(dismissClose).toContain("restoreSpecialRegularSurface(specialSurface);");
expect(cancelClose).toContain("dismissCloseConfirm();");
expect(tabBarSource).toContain("if (!open) dismissCloseConfirm();");
it("keeps the driver update badge on the driver store pill", () => {
expect(tabBarSource).toContain("agentDriverUpdateCount");
expect(tabBarSource).toContain("aria-label=\"t('toolbar.updatableDriverCount')\"");
});
});
describe("AppTabBar overflow search", () => {
it("filters every open tab by its display and source titles", () => {
expect(tabBarSource).toContain('const tabSearchQuery = ref("");');
expect(tabBarSource).toContain("const filteredOpenTabs = computed(() => {");
expect(tabBarSource).toContain("return displayedTabs.value.filter((tab) => tabMatchesSearch(tab, query));");
expect(tabBarSource).toContain("return tabTitleText(tab).toLocaleLowerCase().includes(query) || tab.title.toLocaleLowerCase().includes(query) || connectionName.toLocaleLowerCase().includes(query);");
describe("Group tabbar inherited presentation", () => {
it("imports the shared tab bar stylesheet so pills, scrollbar, and wrap styles apply", () => {
expect(groupTabBarSource).toContain('import "./appTabBar.css"');
expect(groupTabBarSource).toContain("app-tab-pill");
expect(groupTabBarSource).toContain("app-tab-scrollbar");
expect(groupTabBarSource).toContain("app-tab-bar");
});
it("provides the same focused search control and empty state in both overflow menus", () => {
expect(tabBarSource.match(/<Input data-tab-search-input=/g)).toHaveLength(2);
expect(tabBarSource.match(/v-for="tab in filteredOpenTabs"/g)).toHaveLength(2);
expect(tabBarSource.match(/tabs\.noMatchingTabs/g)).toHaveLength(2);
expect(tabBarSource).toContain('[data-tab-search-input="regular"]');
expect(tabBarSource).toContain('[data-tab-search-input="fixed"]');
});
});
describe("AppTabBar query execution status", () => {
it("replaces the icon through one shared status component in every tab surface", () => {
expect(tabBarSource).toContain('import TabExecutionStatus from "@/components/layout/TabExecutionStatus.vue";');
expect(tabBarSource.match(/<TabExecutionStatus :tab="tab">/g)).toHaveLength(4);
expect(tabBarSource.match(/<\/TabExecutionStatus>/g)).toHaveLength(4);
});
});
describe("AppTabBar collapsible groups", () => {
it("supports a persistent icon-only vertical tab bar without losing tab actions", () => {
expect(tabBarSource).toContain("tabBarCollapsed?: boolean;");
expect(tabBarSource).toContain('"toggle-collapse": [];');
expect(tabBarSource).toContain('return { width: "3.5rem", flex: "0 0 3.5rem" };');
expect(tabBarSource).toContain('<component :is="tabBarCollapseIcon"');
expect(tabBarSource).toContain("@click=\"emit('toggle-collapse')\"");
expect(tabBarSource).toContain('v-if="!isTabBarCollapsed" class="panel-resize-handle"');
expect(tabBarSource).toContain(".vertical-tab-layout--collapsed .app-tab-pill");
expect(tabBarSource).toContain(".vertical-tab-layout.vertical-tab-layout--collapsed .tab-group-tab::before");
expect(tabBarSource.match(/v-if="isTabBarCollapsed && isDirtyTab\(tab\)"/g)).toHaveLength(2);
expect(tabBarSource).toMatch(/\(collapsed\) => \{\s*if \(collapsed\) tabSearchQuery\.value = "";/);
});
it("keeps horizontal placements immune to the persisted vertical collapse state", () => {
expect(tabBarSource).toContain("const isTabBarCollapsed = computed(() => isVerticalLayout.value && !!props.tabBarCollapsed);");
expect(tabBarSource).not.toContain('v-else-if="!tabBarCollapsed"');
expect(tabBarSource).not.toContain('v-if="!tabBarCollapsed" class="min-w-0 truncate');
expect(tabBarSource).not.toContain('v-if="tabBarCollapsed && isDirtyTab');
// The only raw-prop guards left render inside the vertical-only toolbar.
expect(tabBarSource.match(/!tabBarCollapsed/g)).toHaveLength(3);
});
it("keeps only grouping and collapse in the compact vertical toolbar", () => {
expect(tabBarSource).toContain('const verticalTabToolbarButtonClass = "inline-flex h-7 w-7');
expect(tabBarSource.match(/:trigger-class="verticalTabToolbarButtonClass"/g)).toHaveLength(1);
const toolbar = sourceBetween('<div v-if="!tabBarCollapsed" class="flex shrink-0 items-center gap-0">', "</div>\n <button");
expect(toolbar).toContain(':trigger-icon="ListFilter"');
expect(toolbar).not.toContain(':trigger-icon="ArrowDownUp"');
expect(toolbar).not.toContain(':trigger-icon="PanelTop"');
expect(tabBarSource).toContain('class="flex shrink-0 items-center gap-0.5 border-b p-1.5"');
expect(tabBarSource).toContain('class="flex shrink-0 items-center gap-0"');
});
it("keeps separate collapsed state for regular and fixed groups", () => {
expect(tabBarSource).toContain("const collapsedTabGroups = ref<Set<string>>(new Set());");
expect(tabBarSource).toContain('return `${tab.pinned ? "fixed" : "regular"}:${tabGroupKey(tab)}`;');
expect(tabBarSource).toContain("function toggleTabGroup(tab: QueryTab)");
expect(tabBarSource.match(/:aria-expanded="!isTabGroupCollapsed\(tab\)"/g)).toHaveLength(2);
expect(tabBarSource.match(/<CustomContextMenu v-if="!isTabGroupCollapsed\(tab\)"/g)).toHaveLength(2);
});
it("temporarily reveals matches and expands a collapsed group when one of its tabs becomes active", () => {
expect(tabBarSource).toContain('if (settingsStore.editorSettings.tabGroupMode === "none" || tabSearchQuery.value.trim()) return false;');
expect(tabBarSource).toContain("expandTabGroupForTab(tabId);");
});
it("uses compact group pills and places the accent next to content for top and bottom bars", () => {
expect(tabBarSource).toContain(':data-placement="settingsStore.editorSettings.tabPlacement"');
expect(tabBarSource).toContain(".app-tab-bar:not(.vertical-tab-layout) .tab-group-header");
expect(tabBarSource).toContain(".app-tab-bar:not(.vertical-tab-layout) .tab-group-header::after");
expect(tabBarSource).toContain(".app-tab-bar:not(.vertical-tab-layout) .tab-group-header--collapsed::after");
expect(tabBarSource).toContain('.app-tab-bar:not(.vertical-tab-layout)[data-placement="bottom"] .tab-group-tab::after');
expect(tabBarSource).toContain(".app-tab-bar:not(.vertical-tab-layout) .tab-group-tab--last::after");
expect(tabBarSource).toContain(".vertical-tab-layout .tab-group-tab::before");
});
it("supports persistent names and colors from the group header context menu", () => {
expect(tabBarSource.match(/:items="\(\) => getTabGroupMenuItems\(tab\)"/g)).toHaveLength(2);
expect(tabBarSource).toContain('["#2563eb", "#d97706", "#7c3aed", "#059669", "#dc2626", "#0891b2", "#db2777", "#475569"]');
expect(tabBarSource).toContain("tabGroupCustomizations: customizations");
expect(tabBarSource).toContain("data-tab-group-name-input");
expect(tabBarSource).toContain('type="color"');
});
it("exposes placement, grouping, and sorting preferences from the group context menu", () => {
const preferences = sourceBetween("function getTabPreferenceMenuItems", "function getTabGroupMenuItems");
const groupMenu = sourceBetween("function getTabGroupMenuItems", "function openTabGroupContextMenu");
expect(preferences).toContain('label: t("settings.tabPlacement")');
expect(preferences).toContain("action: () => updateTabPlacement(item.value)");
expect(preferences).toContain('label: t("settings.tabGroup")');
expect(preferences).toContain("action: () => updateTabGroupMode(item.value)");
expect(preferences).toContain('label: t("settings.tabSort")');
expect(preferences).toContain("action: () => updateTabSortMode(item.value)");
expect(preferences.match(/checked: item\.value === settingsStore\.editorSettings\./g)).toHaveLength(3);
expect(groupMenu).toContain("...getTabPreferenceMenuItems()");
});
it("exposes placement, grouping, and sorting preferences from each tab context menu", () => {
const menu = sourceBetween("function getTabMenuItems", "function handleSaveAndClose");
expect(menu).toContain("...getTabPreferenceMenuItems()");
});
it("waits for tab preference persistence and treats the whole group title as the context target", () => {
expect(tabBarSource).toContain("await settingsStore.updateEditorSettingsAndPersist(partial)");
expect(tabBarSource.match(/openTabGroupContextMenu\(\$event, onContextMenu\)/g)).toHaveLength(2);
expect(tabBarSource).toContain("document.getSelection()?.removeAllRanges()");
expect(tabBarSource).toContain("user-select: none;");
});
it("removes the unexplained fixed-tab divider from vertical placement", () => {
expect(tabBarSource).toContain("isVerticalLayout ? 'max-h-[40%] flex-col bg-background pt-1'");
expect(tabBarSource).toContain('<Pin v-if="!isTabBarCollapsed" class="tab-group-pin" aria-hidden="true" />');
});
it("uses a soft active shadow and a single indented rail for sidebar groups", () => {
expect(tabBarSource).toContain("if (isVerticalLayout.value) {");
expect(tabBarSource).toContain('"--app-tab-background": "var(--accent)"');
expect(tabBarSource).toContain('.vertical-tab-layout .app-tab-pill[data-active-tab="true"]');
expect(tabBarSource).toContain("inset 0 0 0 1px color-mix");
expect(tabBarSource).toContain(".vertical-tab-layout .tab-group-header:not(.tab-group-header--collapsed)::after");
expect(tabBarSource).toContain(".vertical-tab-layout .tab-group-tab::after");
expect(tabBarSource).toContain("margin-inline: 2.5rem 0.5rem;");
expect(tabBarSource).toContain("padding-inline-start: 0.65rem !important;");
expect(tabBarSource).toContain("left: -0.65rem;");
expect(tabBarSource).toContain(".vertical-tab-layout .tab-group-tab--first::before");
expect(tabBarSource).toContain("bottom: -1.08rem;");
expect(tabBarSource).toContain("top: 50%;");
expect(tabBarSource).toContain("border-bottom-left-radius: 0.1875rem;");
it("keeps the dirty marker and dirty title styling", () => {
expect(groupTabBarSource).toContain("dirty-tab-marker");
expect(groupTabBarSource).toContain("dirtyTabTitleStyle");
});
});
@@ -11,11 +11,13 @@ describe("ContentArea data-mode header", () => {
expect(chipsBlock.length).toBeGreaterThan(0);
// Fixed max-w-48/max-w-56 caps clipped table names even with plenty of free
// space in the header row (#7880); chips must rely on min-w-0 + truncate so
// they only clip when the row itself runs out of space.
// space in the header row (#7880); chips must rely on truncate so they only
// clip when the row itself runs out of space. The min-w-12 floor keeps
// scrollWidth reporting real overflow for the measured condensation tiers —
// below the floor the tiers condense the row instead of crushing chips.
expect(chipsBlock).not.toContain("max-w-48");
expect(chipsBlock).not.toContain("max-w-56");
expect(chipsBlock).toContain("inline-flex min-w-0 items-center truncate");
expect(chipsBlock).toContain("inline-flex min-w-12 items-center truncate");
});
it("keeps the full table and schema names reachable via title tooltips", () => {
@@ -6,7 +6,11 @@ const contentAreaSource = readFileSync(new URL("../ContentArea.vue", import.meta
describe("ContentArea completed query output", () => {
it("re-evaluates the no-table fallback when tab activation resets the shared view", () => {
expect(contentAreaSource).toContain("props.activeTab.isExecuting, props.activeOutputView] as const");
expect(contentAreaSource).toContain('emit("update:activeOutputView", result ? defaultViewForResult(result) : "summary")');
expect(contentAreaSource).toContain('emit("update:activeOutputView", props.activeTab.id, result ? defaultViewForResult(result) : "summary")');
});
it("scopes the output-view watcher to the shared result surface", () => {
expect(contentAreaSource).toContain("if (props.editorOnly) return;");
});
});
@@ -15,6 +15,7 @@ describe("ContentArea restored data-tab refresh", () => {
it("passes the restored tab's own WHERE/ORDER BY into the reload event", () => {
expect(contentAreaSource).toContain("const { whereInput, orderBy } = restoredDataTabReloadFilters(props.activeTab);");
expect(contentAreaSource).toContain(`emit("reload", undefined, undefined, whereInput, orderBy);`);
// tabId-first contract: the reload event carries the owning tab's id.
expect(contentAreaSource).toContain(`emit("reload", props.activeTab.id, undefined, whereInput, orderBy);`);
});
});
@@ -0,0 +1,185 @@
// @vitest-environment happy-dom
import { createApp, nextTick } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { createI18n } from "vue-i18n";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/components/layout/EditorGroupTabBar.vue", () => ({
default: {
name: "EditorGroupTabBarStub",
template: `<div data-test="group-tabbar" />`,
},
}));
vi.mock("@/components/layout/EditorToolbar.vue", () => ({
default: {
name: "EditorToolbarStub",
props: ["activeTab"],
template: `<div data-test="group-toolbar">{{ activeTab.id }}</div>`,
},
}));
vi.mock("@/components/layout/QueryEditorSurface.vue", () => ({
default: {
name: "QueryEditorSurfaceStub",
props: ["activeTab", "autoFocus"],
template: `<div data-test="query-editor">{{ activeTab.id }}</div>`,
},
}));
vi.mock("@/components/layout/ContentArea.vue", () => ({
default: {
name: "ContentAreaStub",
template: `<div data-test="content-area" />`,
},
}));
vi.mock("@/lib/backend/safeStorage", () => ({
safeLocalStorageGet: () => null,
safeLocalStorageSet: () => undefined,
}));
import EditorGroup from "../EditorGroup.vue";
import { createNoopEditorToolbarActions, EDITOR_TOOLBAR_ACTIONS, type EditorToolbarActions } from "../editorToolbarActions";
import { useQueryStore } from "@/stores/queryStore";
function tab(id: string) {
return {
id,
title: id,
connectionId: "conn-1",
database: "db",
sql: "SELECT 1",
mode: "query",
} as const;
}
function createHost(): HTMLDivElement {
const host = document.createElement("div");
document.body.appendChild(host);
return host;
}
describe("EditorGroup mount contract", () => {
let pinia: ReturnType<typeof createPinia>;
let i18n: ReturnType<typeof createI18n>;
beforeEach(() => {
document.body.innerHTML = "";
pinia = createPinia();
setActivePinia(pinia);
i18n = createI18n({
legacy: false,
locale: "en",
messages: { en: {} },
});
});
it("passes the group active tab to the query editor surface instead of the global active tab", async () => {
const store = useQueryStore();
store.tabs = [tab("tab-a"), tab("tab-b")];
const host = createHost();
const app = createApp(EditorGroup, {
groupId: "group-1",
tabIds: ["tab-a", "tab-b"],
activeTabId: "tab-b",
// Global active tab is intentionally different from the group active tab.
activeTab: tab("tab-a"),
activeConnection: undefined,
executableSql: "SELECT 1",
activeOutputView: "result",
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
blockDangerousRedisCommands: false,
});
app.use(pinia);
app.use(i18n);
app.mount(host);
await nextTick();
const editor = host.querySelector<HTMLElement>('[data-test="query-editor"]');
expect(editor).not.toBeNull();
expect(editor?.textContent).toBe("tab-b");
app.unmount();
host.remove();
});
it("renders its own toolbar for query tabs and routes events to the injected actions", async () => {
const store = useQueryStore();
const queryId = store.createTab("pg-1", "app", "Query 1", "query");
const dataId = store.createTab("pg-1", "app", "rows", "data", "public");
const actions: EditorToolbarActions = {
...createNoopEditorToolbarActions(),
cancelExecution: vi.fn(),
explain: vi.fn(),
saveSql: vi.fn(),
};
const host = createHost();
const app = createApp(EditorGroup, {
groupId: "group-1",
tabIds: [queryId, dataId],
activeTabId: queryId,
activeTab: tab(queryId),
activeConnection: undefined,
executableSql: "SELECT 1",
activeOutputView: "result",
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
blockDangerousRedisCommands: false,
});
app.provide(EDITOR_TOOLBAR_ACTIONS, actions);
app.use(pinia);
app.use(i18n);
app.mount(host);
await nextTick();
// Query tab: the group carries its own toolbar bound to the group tab.
const toolbar = host.querySelector<HTMLElement>('[data-test="group-toolbar"]');
expect(toolbar?.textContent).toBe(queryId);
// Toolbar events flow to the injected actions with the group's tab id.
(toolbar as any).__vueParentComponent.emit("cancel");
(toolbar as any).__vueParentComponent.emit("explain");
(toolbar as any).__vueParentComponent.emit("saveSql", queryId);
await nextTick();
expect(actions.cancelExecution).toHaveBeenCalledWith(queryId);
expect(actions.explain).toHaveBeenCalledWith(queryId);
expect(actions.saveSql).toHaveBeenCalledWith(queryId);
// Data tab: no SQL toolbar — the data grid carries its own built-in bar.
app.unmount();
const app2 = createApp(EditorGroup, {
groupId: "group-1",
tabIds: [queryId, dataId],
activeTabId: dataId,
activeTab: tab(queryId),
activeConnection: undefined,
executableSql: "SELECT 1",
activeOutputView: "result",
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
blockDangerousRedisCommands: false,
});
app2.provide(EDITOR_TOOLBAR_ACTIONS, actions);
app2.use(pinia);
app2.use(i18n);
app2.mount(host);
await nextTick();
expect(host.querySelector('[data-test="group-toolbar"]')).toBeNull();
expect(host.querySelector('[data-test="content-area"]')).not.toBeNull();
app2.unmount();
host.remove();
});
});
@@ -0,0 +1,562 @@
// @vitest-environment happy-dom
import { createApp, nextTick } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { createI18n } from "vue-i18n";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/components/ui/CustomContextMenu.vue", () => ({
default: {
name: "CustomContextMenuStub",
props: ["items"],
template: `<div class="ctx-menu-stub" :data-menu-items="JSON.stringify(items.map((item) => ({ label: item.label, disabled: item.disabled === true, visible: item.visible })))"><slot /></div>`,
},
}));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: { name: "TooltipStub", template: `<div><slot /></div>` },
TooltipTrigger: { name: "TooltipTriggerStub", template: `<div><slot /></div>` },
TooltipContent: { name: "TooltipContentStub", template: `<div><slot /></div>` },
}));
vi.mock("@/components/ui/popover", () => ({
Popover: { name: "PopoverStub", props: ["open"], template: `<div v-if="open"><slot /></div>` },
PopoverContent: { name: "PopoverContentStub", template: `<div><slot /></div>` },
PopoverTrigger: { name: "PopoverTriggerStub", template: `<div><slot /></div>` },
}));
vi.mock("@/components/connection/ReadOnlySessionControl.vue", () => ({
default: { name: "ReadOnlySessionControlStub", template: `<span />` },
}));
vi.mock("@/components/layout/TabExecutionStatus.vue", () => ({
default: { name: "TabExecutionStatusStub", template: `<span><slot /></span>` },
}));
vi.mock("@/components/icons/DatabaseIcon.vue", () => ({
default: { name: "DatabaseIconStub", template: `<span />` },
}));
import EditorGroupTabBar from "../EditorGroupTabBar.vue";
import { useQueryStore } from "@/stores/queryStore";
function createHost(): HTMLDivElement {
const host = document.createElement("div");
document.body.appendChild(host);
return host;
}
interface Mounted {
app: ReturnType<typeof createApp>;
host: HTMLDivElement;
}
function mountBar(groupId: string, tabs: string[], activeTabId: string | null, activePinia: ReturnType<typeof createPinia>, onActivateTab?: (tabId: string) => void): Mounted {
const store = useQueryStore();
const host = createHost();
const app = createApp(EditorGroupTabBar, {
groupId,
tabs: tabs.map((id) => store.tabs.find((candidate) => candidate.id === id)!),
activeTabId,
"onActivate-tab": onActivateTab,
});
// Reuse the active pinia so the component's internal store is the same
// instance the test drives — otherwise drag validation runs against an
// empty store and silently early-returns.
app.use(activePinia);
app.use(
createI18n({
legacy: false,
locale: "en",
messages: {
en: {
contextMenu: {
splitRight: "Split right",
splitDown: "Split down",
changeOrientation: "Change orientation",
unsplit: "Unsplit",
renameTab: "Rename",
duplicateTab: "Duplicate",
copyName: "Copy name",
closeTab: "Close",
closeOtherTabs: "Close other tabs",
closeLeftTabs: "Close left tabs",
closeRightTabs: "Close right tabs",
closeAllTabs: "Close all tabs",
pinTab: "Pin",
unpinTab: "Unpin",
fullTabTitle: "Full title",
compactTabTitle: "Compact title",
},
sidebar: { locateActiveTab: "Locate" },
},
},
}),
);
app.mount(host);
return { app, host };
}
async function settle() {
await nextTick();
await nextTick();
}
function tabPill(host: HTMLElement, tabId: string): HTMLElement {
return host.querySelector<HTMLElement>(`[data-tab-id="${tabId}"]`)!;
}
function pointerDownOn(element: HTMLElement) {
element.dispatchEvent(
new PointerEvent("pointerdown", {
bubbles: true,
button: 0,
buttons: 1,
clientX: 10,
clientY: 10,
pointerId: 1,
}),
);
}
function pointerMoveTo(x: number, y: number) {
window.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, buttons: 1, clientX: x, clientY: y, pointerId: 1 }));
}
function pointerUpAt(x: number, y: number) {
window.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, buttons: 1, clientX: x, clientY: y, pointerId: 1 }));
}
function fakeDropTarget(tabId: string, groupId: string): HTMLElement {
const element = document.createElement("div");
element.dataset.tabId = tabId;
element.dataset.groupId = groupId;
vi.spyOn(element, "getBoundingClientRect").mockReturnValue({ left: 0, right: 100, width: 100, top: 0, height: 30 } as DOMRect);
return element;
}
describe("EditorGroupTabBar behavior", () => {
let pinia: ReturnType<typeof createPinia>;
beforeEach(() => {
document.body.innerHTML = "";
vi.restoreAllMocks();
pinia = createPinia();
setActivePinia(pinia);
});
it("activates a tab on plain click", async () => {
const store = useQueryStore();
const firstId = store.createTab("pg-1", "app", "Query 1", "query");
const secondId = store.createTab("pg-1", "app", "Query 2", "query");
const mainGroup = store.groups[0];
const activated: string[] = [];
const { app, host } = mountBar(mainGroup.id, [firstId, secondId], firstId, pinia, (tabId) => activated.push(tabId));
await settle();
tabPill(host, secondId).click();
await settle();
expect(activated).toEqual([secondId]);
app.unmount();
host.remove();
});
it("suppresses the click that follows a drag, and only that click", async () => {
const store = useQueryStore();
const firstId = store.createTab("pg-1", "app", "Query 1", "query");
const secondId = store.createTab("pg-1", "app", "Query 2", "query");
const mainGroup = store.groups[0];
const activated: string[] = [];
const { app, host } = mountBar(mainGroup.id, [firstId, secondId], firstId, pinia, (tabId) => activated.push(tabId));
await settle();
vi.spyOn(document, "elementFromPoint").mockReturnValue(null);
const pill = tabPill(host, secondId);
pointerDownOn(pill);
pointerMoveTo(80, 60);
pointerUpAt(80, 60);
pill.click();
await settle();
// The drag consumed its own click; the tab must not be activated…
expect(activated).toEqual([]);
// …but a fresh, ordinary click still activates.
pill.click();
await settle();
expect(activated).toEqual([secondId]);
app.unmount();
host.remove();
});
it("does not arm a drag when the primary button is not held", async () => {
const store = useQueryStore();
const firstId = store.createTab("pg-1", "app", "Query 1", "query");
const secondId = store.createTab("pg-1", "app", "Query 2", "query");
const mainGroup = store.groups[0];
const { app, host } = mountBar(mainGroup.id, [firstId, secondId], firstId, pinia);
await settle();
vi.spyOn(document, "elementFromPoint").mockReturnValue(null);
// macOS reports a trackpad tap as a mouse pointer with button=0 but buttons=0.
tabPill(host, secondId).dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0, buttons: 0, clientX: 10, clientY: 10, pointerId: 1 }));
window.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, buttons: 0, clientX: 80, clientY: 60, pointerId: 1 }));
window.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, clientX: 80, clientY: 60, pointerId: 1 }));
await settle();
expect(store.groups.find((group) => group.id === mainGroup.id)?.tabIds).toContain(secondId);
expect(document.body.style.cursor).toBe("");
app.unmount();
host.remove();
});
it("cancels a pending drag when the primary button is released before movement", async () => {
const store = useQueryStore();
const firstId = store.createTab("pg-1", "app", "Query 1", "query");
const secondId = store.createTab("pg-1", "app", "Query 2", "query");
const mainGroup = store.groups[0];
const { app, host } = mountBar(mainGroup.id, [firstId, secondId], firstId, pinia);
await settle();
vi.spyOn(document, "elementFromPoint").mockReturnValue(null);
const pill = tabPill(host, secondId);
pointerDownOn(pill);
window.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, buttons: 0, clientX: 80, clientY: 10, pointerId: 1 }));
window.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, buttons: 1, clientX: 90, clientY: 10, pointerId: 1 }));
window.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, clientX: 90, clientY: 10, pointerId: 1 }));
await settle();
expect(store.groups.find((group) => group.id === mainGroup.id)?.tabIds).toContain(secondId);
expect(document.body.style.cursor).toBe("");
app.unmount();
host.remove();
});
it("cancels an active drag when the primary button is no longer held", async () => {
const store = useQueryStore();
const firstId = store.createTab("pg-1", "app", "Query 1", "query");
const secondId = store.createTab("pg-1", "app", "Query 2", "query");
const mainGroup = store.groups[0];
const { app, host } = mountBar(mainGroup.id, [firstId, secondId], firstId, pinia);
await settle();
vi.spyOn(document, "elementFromPoint").mockReturnValue(null);
const pill = tabPill(host, secondId);
pointerDownOn(pill);
pointerMoveTo(80, 60);
expect(document.body.style.cursor).toBe("grabbing");
window.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, buttons: 0, clientX: 90, clientY: 60, pointerId: 1 }));
await settle();
expect(document.body.style.cursor).toBe("");
expect(document.body.style.userSelect).toBe("");
expect(store.groups.find((group) => group.id === mainGroup.id)?.tabIds).toContain(secondId);
app.unmount();
host.remove();
});
it("ignores movement from a different pointer", async () => {
const store = useQueryStore();
const firstId = store.createTab("pg-1", "app", "Query 1", "query");
const secondId = store.createTab("pg-1", "app", "Query 2", "query");
const mainGroup = store.groups[0];
const { app, host } = mountBar(mainGroup.id, [firstId, secondId], firstId, pinia);
await settle();
vi.spyOn(document, "elementFromPoint").mockReturnValue(null);
const pill = tabPill(host, secondId);
pill.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0, buttons: 1, clientX: 10, clientY: 10, pointerId: 7 }));
window.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, buttons: 1, clientX: 90, clientY: 10, pointerId: 8 }));
await settle();
expect(document.body.style.cursor).toBe("");
expect(store.groups.find((group) => group.id === mainGroup.id)?.tabIds).toContain(secondId);
window.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, clientX: 90, clientY: 10, pointerId: 7 }));
app.unmount();
host.remove();
});
it("clears completed-drag click suppression on a later trackpad tap", async () => {
const store = useQueryStore();
const firstId = store.createTab("pg-1", "app", "Query 1", "query");
const secondId = store.createTab("pg-1", "app", "Query 2", "query");
const mainGroup = store.groups[0];
const activated: string[] = [];
const { app, host } = mountBar(mainGroup.id, [firstId, secondId], firstId, pinia, (tabId) => activated.push(tabId));
await settle();
vi.spyOn(document, "elementFromPoint").mockReturnValue(null);
const pill = tabPill(host, secondId);
pointerDownOn(pill);
pointerMoveTo(80, 60);
pointerUpAt(80, 60);
// The completed drag left click suppression armed; a trackpad tap
// (pointerdown with buttons=0) must consume it without arming a drag.
pill.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0, buttons: 0, clientX: 10, clientY: 10, pointerId: 1 }));
window.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, buttons: 0, clientX: 10, clientY: 12, pointerId: 1 }));
pill.click();
await settle();
expect(activated).toEqual([secondId]);
expect(document.body.style.cursor).toBe("");
app.unmount();
host.remove();
});
it("moves a dragged tab to the target group under the payload's source group", async () => {
const store = useQueryStore();
const firstId = store.createTab("pg-1", "app", "Query 1", "query");
const secondId = store.createTab("pg-1", "app", "Query 2", "query");
const mainGroup = store.groups[0];
const { app, host } = mountBar(mainGroup.id, [firstId, secondId], firstId, pinia);
await settle();
// Drag the second tab over the (virtual) right group's strip.
vi.spyOn(document, "elementFromPoint").mockReturnValue(fakeDropTarget(firstId, "target-group"));
// Seed a second group to receive the drop.
store.groups = [mainGroup, { id: "target-group", tabIds: [], activeTabId: null }];
const pill = tabPill(host, secondId);
pointerDownOn(pill);
pointerMoveTo(300, 10);
pointerUpAt(300, 10);
await settle();
expect(store.groups.find((group) => group.id === "target-group")?.tabIds).toContain(secondId);
expect(store.groups.find((group) => group.id === mainGroup.id)?.tabIds).not.toContain(secondId);
app.unmount();
host.remove();
});
it("ignores a drop when the tab's current owner no longer matches the payload", async () => {
const store = useQueryStore();
const firstId = store.createTab("pg-1", "app", "Query 1", "query");
const secondId = store.createTab("pg-1", "app", "Query 2", "query");
const mainGroup = store.groups[0];
const { app, host } = mountBar(mainGroup.id, [firstId, secondId], firstId, pinia);
await settle();
vi.spyOn(document, "elementFromPoint").mockReturnValue(fakeDropTarget(firstId, "target-group"));
store.groups = [mainGroup, { id: "target-group", tabIds: [], activeTabId: null }];
const pill = tabPill(host, secondId);
pointerDownOn(pill);
// While the button is held, the tab is relocated behind the drag's back —
// the stale payload no longer matches the current owner.
store.moveTabToGroup(secondId, "target-group");
pointerMoveTo(300, 10);
pointerUpAt(300, 10);
await settle();
// Only the intervening store move happened; the drop itself was rejected.
expect(store.groups.find((group) => group.id === "target-group")?.tabIds).toEqual([secondId]);
expect(store.groups.find((group) => group.id === mainGroup.id)?.tabIds).toEqual([firstId]);
app.unmount();
host.remove();
});
it("disables the split actions while only one tab exists, enabling them once a second tab joins", async () => {
const store = useQueryStore();
const onlyId = store.createTab("pg-1", "app", "Query 1", "query");
const mainGroup = store.groups[0];
const { app, host } = mountBar(mainGroup.id, [onlyId], onlyId, pinia);
await settle();
// Splitting the only tab would return to the same single-group layout, so
// the actions render disabled instead of doing nothing.
const splitItems = (host: HTMLElement) => JSON.parse(host.querySelector<HTMLElement>(".ctx-menu-stub")!.dataset.menuItems ?? "[]").filter((item: { label: string }) => item.label === "Split right" || item.label === "Split down");
expect(splitItems(host)).toHaveLength(2);
for (const item of splitItems(host)) {
expect(item).toMatchObject({ disabled: true });
}
store.createTab("pg-1", "app", "Query 2", "query");
await settle();
for (const item of splitItems(host)) {
expect(item).toMatchObject({ disabled: false });
}
app.unmount();
host.remove();
});
it("offers the split actions for non-query tabs, enabled like query tabs", async () => {
const store = useQueryStore();
const queryId = store.createTab("pg-1", "app", "Query 1", "query");
const dataId = store.createTab("pg-1", "app", "users", "data", "public");
const mainGroup = store.groups[0];
const { app, host } = mountBar(mainGroup.id, [queryId, dataId], dataId, pinia);
await settle();
const pill = tabPill(host, dataId);
const stub = pill.closest<HTMLElement>(".ctx-menu-stub")!;
const items = JSON.parse(stub.dataset.menuItems ?? "[]");
const splitItems = items.filter((item: { label: string }) => item.label === "Split right" || item.label === "Split down");
expect(splitItems).toHaveLength(2);
for (const item of splitItems) {
expect(item).toMatchObject({ disabled: false });
}
app.unmount();
host.remove();
});
it("keeps split actions visible but disabled once four groups exist", async () => {
const store = useQueryStore();
const firstId = store.createTab("pg-1", "app", "Query 1", "query");
const secondId = store.createTab("pg-1", "app", "Query 2", "query");
const thirdId = store.createTab("pg-1", "app", "Query 3", "query");
const fourthId = store.createTab("pg-1", "app", "Query 4", "query");
const fifthId = store.createTab("pg-1", "app", "Query 5", "query");
const mainGroup = store.groups[0];
const { app, host } = mountBar(mainGroup.id, [firstId, secondId, thirdId, fourthId, fifthId], firstId, pinia);
await settle();
// Two tabs, two groups.
store.splitTabRight(secondId);
await settle();
const menus = host.querySelectorAll<HTMLElement>(".ctx-menu-stub");
const splitItem = (menu: HTMLElement) => JSON.parse(menu.dataset.menuItems ?? "[]").find((item: { label: string }) => item.label === "Split right");
expect(splitItem(menus[0]!)).toMatchObject({ disabled: false });
store.splitTabDown(thirdId);
store.splitTabDown(fourthId);
await settle();
expect(store.groups.length).toBe(4);
const disabledSplit = splitItem(menus[0]!);
expect(disabledSplit).toMatchObject({ disabled: true });
// The store rejects a fifth split with the same capacity rule.
expect(store.splitTabRight(fifthId)).toBe(false);
app.unmount();
host.remove();
});
it("offers close-left between close-other and close-right, disabled when nothing is to the left", async () => {
const store = useQueryStore();
const firstId = store.createTab("pg-1", "app", "Query 1", "query");
const secondId = store.createTab("pg-1", "app", "Query 2", "query");
const mainGroup = store.groups[0];
const { app, host } = mountBar(mainGroup.id, [firstId, secondId], firstId, pinia);
await settle();
// One stub per tab; the stub wraps the tab pill, so scope by ancestor.
// The stub only serializes label/disabled/visible — the closing behavior
// itself is covered by queryStore.groups.spec.
const menuFor = (tabId: string) => {
const pill = tabPill(host, tabId);
const stub = pill.closest<HTMLElement>(".ctx-menu-stub");
return JSON.parse(stub!.dataset.menuItems ?? "[]");
};
const labels = (tabId: string) => menuFor(tabId).map((item: { label: string }) => item.label);
// First tab: nothing to its left — the action exists but is disabled.
expect(menuFor(firstId).find((item: { label: string }) => item.label === "Close left tabs")).toMatchObject({ disabled: true });
const order = labels(firstId);
expect(order.indexOf("Close left tabs")).toBeGreaterThan(order.indexOf("Close other tabs"));
expect(order.indexOf("Close left tabs")).toBeLessThan(order.indexOf("Close right tabs"));
// Second tab: a tab exists to its left — the action becomes enabled.
expect(menuFor(secondId).find((item: { label: string }) => item.label === "Close left tabs")).toMatchObject({ disabled: false });
app.unmount();
host.remove();
});
it("flushes pending grid edits via before-tab-switch before activating another tab", async () => {
const store = useQueryStore();
const firstId = store.createTab("pg-1", "app", "Query 1", "query");
const secondId = store.createTab("pg-1", "app", "Query 2", "query");
const mainGroup = store.groups[0];
const { app, host } = mountBar(mainGroup.id, [firstId, secondId], firstId, pinia);
await settle();
const events: Array<CustomEvent | Event> = [];
const listener = (event: Event) => events.push(event as CustomEvent);
window.addEventListener("dbx:before-tab-switch", listener);
// The legacy bar flushed pending edits on pointerdown, before activation.
pointerDownOn(tabPill(host, secondId));
tabPill(host, secondId).click();
await settle();
window.removeEventListener("dbx:before-tab-switch", listener);
expect(events).toHaveLength(1);
expect((events[0] as CustomEvent).detail).toEqual({ tabId: secondId, fromTabId: firstId });
app.unmount();
host.remove();
});
it("dims the dragged pill and shows the insertion indicator on the hovered pill", async () => {
const store = useQueryStore();
const firstId = store.createTab("pg-1", "app", "Query 1", "query");
const secondId = store.createTab("pg-1", "app", "Query 2", "query");
const mainGroup = store.groups[0];
const { app, host } = mountBar(mainGroup.id, [firstId, secondId], firstId, pinia);
await settle();
const fake = fakeDropTarget(secondId, mainGroup.id);
vi.spyOn(document, "elementFromPoint").mockReturnValue(fake);
const sourcePill = tabPill(host, firstId);
const targetPill = tabPill(host, secondId);
pointerDownOn(sourcePill);
pointerMoveTo(300, 10);
await settle();
// Dragged pill dims; hovered pill shows the after-insertion ring line.
expect(sourcePill.style.opacity).toBe("0.4");
expect(targetPill.style.boxShadow).toBe("inset -3px 0 0 0 var(--ring)");
// Crossing the pill's midpoint flips the indicator to "before".
vi.spyOn(fake, "getBoundingClientRect").mockReturnValue({ left: 0, right: 600, width: 600, top: 0, height: 30 } as DOMRect);
pointerMoveTo(20, 10);
await settle();
expect(targetPill.style.boxShadow).toBe("inset 3px 0 0 0 var(--ring)");
// Dropping clears every drag visual.
pointerUpAt(20, 10);
await settle();
expect(sourcePill.style.opacity).toBe("");
expect(targetPill.style.boxShadow).toBe("");
app.unmount();
host.remove();
});
it("does not arm the drag for touch input", async () => {
const store = useQueryStore();
const firstId = store.createTab("pg-1", "app", "Query 1", "query");
const secondId = store.createTab("pg-1", "app", "Query 2", "query");
const mainGroup = store.groups[0];
const { app, host } = mountBar(mainGroup.id, [firstId, secondId], firstId, pinia);
await settle();
const sourcePill = tabPill(host, firstId);
sourcePill.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 10, clientY: 10, pointerType: "touch" }));
pointerMoveTo(300, 10);
await settle();
expect(sourcePill.style.opacity).toBe("");
app.unmount();
host.remove();
});
});
@@ -0,0 +1,421 @@
// @vitest-environment happy-dom
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { createI18n } from "vue-i18n";
import { createApp, nextTick } from "vue";
import EditorGroupTabBar from "../EditorGroupTabBar.vue";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: { name: "TooltipStub", template: `<div><slot /></div>` },
TooltipTrigger: { name: "TooltipTriggerStub", template: `<div><slot /></div>` },
TooltipContent: { name: "TooltipContentStub", template: `<div><slot /></div>` },
}));
vi.mock("@/components/ui/popover", () => ({
Popover: { name: "PopoverStub", props: ["open"], template: `<div v-if="open"><slot /></div>` },
PopoverContent: { name: "PopoverContentStub", template: `<div><slot /></div>` },
PopoverTrigger: { name: "PopoverTriggerStub", template: `<div><slot /></div>` },
}));
const specDir = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(resolve(specDir, "../EditorGroupTabBar.vue"), "utf8");
const sharedStyles = readFileSync(resolve(specDir, "../appTabBar.css"), "utf8");
describe("EditorGroupTabBar semantic tab groups", () => {
it("keeps separate collapsed state for pinned and regular clusters under the same key", () => {
expect(source).toContain("const collapsedTabGroups = ref<Set<string>>(new Set());");
expect(source).toContain('return `${tab.pinned ? "fixed" : "regular"}:${tabGroupKey(tab)}`;');
expect(source).toContain("function toggleTabGroup(tab: QueryTab)");
expect(source).toContain(':aria-expanded="!isTabGroupCollapsed(entry.tab)"');
});
it("expands a collapsed group when one of its tabs becomes active", () => {
expect(source).toContain("expandTabGroupForTab(tabId);");
});
it("supports persistent names and colors from the group header context menu", () => {
expect(source).toContain('["#2563eb", "#d97706", "#7c3aed", "#059669", "#dc2626", "#0891b2", "#db2777", "#475569"]');
expect(source).toContain("tabGroupCustomizations: customizations");
expect(source).toContain("data-tab-group-name-input");
expect(source).toContain('type="color"');
});
it("exposes placement, grouping, and sorting preferences from the group context menu", () => {
const preferences = sourceBetween("function getTabPreferenceMenuItems", "function getTabGroupMenuItems");
const groupMenu = sourceBetween("function getTabGroupMenuItems", "function openTabGroupContextMenu");
expect(preferences).toContain('label: t("settings.tabPlacement")');
expect(preferences).toContain("action: () => updateTabPlacement(item.value)");
expect(preferences).toContain('label: t("settings.tabGroup")');
expect(preferences).toContain("action: () => updateTabGroupMode(item.value)");
expect(preferences).toContain('label: t("settings.tabSort")');
expect(preferences).toContain("action: () => updateTabSortMode(item.value)");
expect(preferences.match(/checked: item\.value === settingsStore\.editorSettings\./g)).toHaveLength(3);
expect(groupMenu).toContain("...getTabPreferenceMenuItems()");
});
it("exposes preferences and the group close from each tab context menu", () => {
const menuStart = source.indexOf("function getTabMenuItems");
const menuEnd = source.indexOf("function handleTabDoubleClick");
expect(menuStart).toBeGreaterThanOrEqual(0);
expect(menuEnd).toBeGreaterThan(menuStart);
const menu = source.slice(menuStart, menuEnd);
expect(menu).toContain("...getTabPreferenceMenuItems()");
expect(menu).toContain("action: () => closeTabGroup(tab)");
expect(menu).toContain('visible: settingsStore.editorSettings.tabGroupMode !== "none"');
});
it("closes the global semantic group by key, not just this pane's cluster", () => {
// D1 (amended): closing a cluster is destructive and stays bar-local —
// scoped to this pane's tabs (props.tabs), still within the trigger's
// pinned section. Profile edits (rename/color/reset) keep global reach.
const closeGroup = sourceBetween("function tabsInSemanticGroup", "function getTabPreferenceMenuItems");
expect(closeGroup).toContain("props.tabs.filter((item) => item.pinned === tab.pinned && tabGroupKey(item) === groupKey)");
expect(closeGroup).toContain("queryStore.closeTabsByIds(tabsToClose, finalActiveTabId)");
});
it("waits for tab preference persistence and treats the whole group title as the context target", () => {
expect(source).toContain("await settingsStore.updateEditorSettingsAndPersist(partial)");
expect(source).toContain("openTabGroupContextMenu($event, onContextMenu)");
expect(source).toContain("document.getSelection()?.removeAllRanges()");
});
it("applies the group rail classes to clustered pills", () => {
// The horizontal underline and vertical indent rails only render when the
// pill carries tab-group-tab with its --first/--last markers.
expect(source).toContain("'tab-group-tab': entry.grouping,");
expect(source).toContain("'tab-group-tab--first': entry.grouping && entry.groupFirst,");
expect(source).toContain("'tab-group-tab--last': entry.grouping && entry.groupLast,");
expect(sharedStyles).toContain(".app-tab-bar:not(.vertical-tab-layout) .tab-group-tab::after");
});
it("hides collapsed cluster pills but reveals them while searching", () => {
expect(source).toContain("grouping && !tabSearchQuery.value.trim() && isTabGroupCollapsed(tab)");
});
it("keeps wrap layout out of the vertical placement", () => {
expect(source).toContain('const isWrapLayout = computed(() => !isVerticalLayout.value && settingsStore.editorSettings.tabLayout === "wrap");');
});
it("uses compact group pills and places the accent next to content for horizontal bars", () => {
expect(source).toContain(':data-placement="settingsStore.editorSettings.tabPlacement"');
expect(sharedStyles).toContain(".app-tab-bar:not(.vertical-tab-layout) .tab-group-header");
expect(sharedStyles).toContain(".app-tab-bar:not(.vertical-tab-layout) .tab-group-header::after");
expect(sharedStyles).toContain(".app-tab-bar:not(.vertical-tab-layout) .tab-group-header--collapsed::after");
expect(sharedStyles).toContain('.app-tab-bar:not(.vertical-tab-layout)[data-placement="bottom"] .tab-group-tab::after');
expect(sharedStyles).toContain(".app-tab-bar:not(.vertical-tab-layout) .tab-group-tab--last::after");
});
function sourceBetween(start: string, end: string): string {
const startIndex = source.indexOf(start);
const endIndex = source.indexOf(end, startIndex + start.length);
expect(startIndex).toBeGreaterThanOrEqual(0);
expect(endIndex).toBeGreaterThan(startIndex);
return source.slice(startIndex, endIndex);
}
});
describe("EditorGroupTabBar vertical placement", () => {
const groupSource = readFileSync(resolve(specDir, "../EditorGroup.vue"), "utf8");
const workspaceSource = readFileSync(resolve(specDir, "../SqlEditorWorkspace.vue"), "utf8");
const appSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../../../App.vue"), "utf8");
it("renders each pane's own bar as a vertical strip when placement is left/right", () => {
expect(source).toContain('const isVerticalLayout = computed(() => settingsStore.editorSettings.tabPlacement === "left" || settingsStore.editorSettings.tabPlacement === "right");');
expect(source).toContain('isVerticalLayout.value && props.tabBarCollapsed ? "vertical-tab-layout--collapsed" : ""');
// The strip flips to a column with vertical scrolling; no horizontal strip remains.
expect(source).toContain("isVerticalLayout ? 'flex-col items-stretch overflow-y-auto overflow-x-hidden py-1'");
});
it("positions each pane's bar by the global placement without mixing directions", () => {
expect(groupSource).toContain('case "bottom":');
expect(groupSource).toContain('return "flex-col-reverse";');
expect(groupSource).toContain('case "left":');
expect(groupSource).toContain('return "flex-row";');
expect(groupSource).toContain('case "right":');
expect(groupSource).toContain('return "flex-row-reverse";');
// The editor toolbar stays atop the pane's content column in every placement.
expect(groupSource).toContain('class="flex min-h-0 min-w-0 flex-1 flex-col"');
});
it("shares the vertical width/collapse state from App through the pane chain", () => {
expect(appSource).toContain(':tab-bar-width="tabBarWidth"');
expect(appSource).toContain(':tab-bar-collapsed="tabBarCollapsed"');
expect(appSource).toContain('@start-resize="startTabBarResize"');
expect(appSource).toContain('@toggle-collapse="toggleTabBarCollapsed"');
expect(workspaceSource).toContain(':tab-bar-width="tabBarWidth"');
expect(workspaceSource).toContain("@start-resize=\"emit('start-resize', $event)\"");
expect(groupSource).toContain(':tab-bar-width="tabBarWidth"');
// Dragging any pane's handle drives the shared resize handler.
expect(source).toContain("@mousedown=\"emit('start-resize', $event)\"");
expect(source).toContain("@click=\"emit('toggle-collapse')\"");
});
it("keeps horizontal placements immune to the persisted collapse state", () => {
expect(source).toContain("const isTabBarCollapsed = computed(() => isVerticalLayout.value && !!props.tabBarCollapsed);");
expect(source).toContain("if (!isVerticalLayout.value) return undefined;");
});
it("uses the sidebar rail and soft active shadow for vertical pills", () => {
expect(sharedStyles).toContain(".vertical-tab-layout .tab-group-tab::before");
expect(sharedStyles).toContain('.vertical-tab-layout .app-tab-pill[data-active-tab="true"]');
expect(sharedStyles).toContain("inset 0 0 0 1px color-mix");
expect(sharedStyles).toContain(".vertical-tab-layout .tab-group-header:not(.tab-group-header--collapsed)::after");
expect(sharedStyles).toContain("margin-inline: 2.5rem 0.5rem;");
});
});
function createHost(): HTMLDivElement {
const host = document.createElement("div");
document.body.appendChild(host);
return host;
}
async function settle() {
await nextTick();
await nextTick();
}
function mountBar(groupId: string, tabs: ReturnType<ReturnType<typeof useQueryStore>["tabs"]>[number][], activeTabId: string | null, activePinia: ReturnType<typeof createPinia>, extraProps: Record<string, unknown> = {}) {
const host = createHost();
const app = createApp(EditorGroupTabBar, {
groupId,
tabs,
activeTabId,
...extraProps,
});
app.use(activePinia);
app.use(
createI18n({
legacy: false,
locale: "en",
messages: {
en: {
contextMenu: {
renameTab: "Rename",
duplicateTab: "Duplicate",
copyName: "Copy name",
closeTab: "Close",
closeOtherTabs: "Close other tabs",
closeLeftTabs: "Close left tabs",
closeRightTabs: "Close right tabs",
closeAllTabs: "Close all tabs",
closeTabGroup: "Close group",
editTabGroup: "Edit group",
resetTabGroup: "Reset group",
pinTab: "Pin",
unpinTab: "Unpin",
fullTabTitle: "Full title",
compactTabTitle: "Compact title",
splitRight: "Split right",
splitDown: "Split down",
changeOrientation: "Change orientation",
unsplit: "Unsplit",
},
sidebar: { locateActiveTab: "Locate" },
settings: {
tabPlacement: "Tab placement",
tabPlacementTop: "Top",
tabPlacementBottom: "Bottom",
tabPlacementLeft: "Left",
tabPlacementRight: "Right",
tabGroup: "Group tabs by",
tabGroupNone: "None",
tabGroupDatabaseType: "Database type",
tabGroupConnection: "Connection",
tabSort: "Sort tabs",
tabSortManual: "Manual",
tabSortCreated: "Created",
tabSortTitle: "Title",
},
tabs: {
openInNewWindow: "Open in new window",
settingsSaveFailed: "Save failed: {message}",
editGroupTitle: "Edit group {name}",
groupName: "Name",
groupColor: "Color",
groupColorAuto: "Auto",
groupColorCustom: "Custom",
resetGroup: "Reset",
openTabs: "Open tabs",
searchOpenTabs: "Search",
noMatchingTabs: "No matching tabs",
},
common: { cancel: "Cancel", save: "Save" },
toolbar: { formatSqlFailed: "Format failed" },
grid: { copyFailed: "Copy failed: {message}" },
connection: { copied: "Copied" },
},
},
}),
);
app.mount(host);
return { app, host };
}
describe("EditorGroupTabBar group behavior", () => {
let pinia: ReturnType<typeof createPinia>;
beforeEach(() => {
document.body.innerHTML = "";
vi.restoreAllMocks();
pinia = createPinia();
setActivePinia(pinia);
});
it("renders one header per connection cluster and collapses it to a count badge", async () => {
const store = useQueryStore();
const settings = useSettingsStore();
settings.editorSettings.tabGroupMode = "connection";
const pgA = store.createTab("pg-1", "app", "PG 1", "query");
store.createTab("pg-1", "app", "PG 2", "query");
const my = store.createTab("mysql-1", "app", "MY 1", "query");
const mainGroup = store.groups[0];
const { app, host } = mountBar(mainGroup.id, store.tabs.slice(), pgA, pinia);
await settle();
const headers = Array.from(host.querySelectorAll<HTMLButtonElement>(".tab-group-header"));
// Sorted by group key: mysql-1 clusters before pg-1.
expect(headers.map((header) => header.title)).toEqual(["mysql-1", "pg-1"]);
expect(host.querySelectorAll("[data-tab-id]").length).toBe(3);
// Collapse the pg cluster: its pills hide, the count badge appears.
headers[1]!.click();
await settle();
expect(Array.from(host.querySelectorAll("[data-tab-id]")).map((pill) => pill.getAttribute("data-tab-id"))).toEqual([my]);
const pgHeader = host.querySelectorAll<HTMLButtonElement>(".tab-group-header")[1]!;
expect(pgHeader.querySelector(".tab-group-count")?.textContent).toBe("2");
expect(pgHeader.getAttribute("aria-expanded")).toBe("false");
// Expand again.
pgHeader.click();
await settle();
expect(host.querySelectorAll("[data-tab-id]").length).toBe(3);
app.unmount();
host.remove();
});
it("exposes the drag-back hit-test anchor and highlights itself as the detached drop target", async () => {
const store = useQueryStore();
const tabId = store.createTab("pg-1", "app", "PG 1", "query");
const mainGroup = store.groups[0];
const { app, host } = mountBar(mainGroup.id, store.tabs.slice(), tabId, pinia, { detachedDropTarget: true });
await settle();
const bar = host.querySelector<HTMLElement>(".app-tab-bar");
// Dropping a detached window over any pane's strip returns the tab, so
// every strip must carry the hit-test anchor App unions rects over.
expect(bar?.hasAttribute("data-main-tab-bar")).toBe(true);
expect(bar?.classList.contains("ring-2")).toBe(true);
expect(bar?.classList.contains("ring-inset")).toBe(true);
app.unmount();
host.remove();
});
it("closing a group stays within the invoking pane, sparing the same-key cluster elsewhere", async () => {
const store = useQueryStore();
const settings = useSettingsStore();
settings.editorSettings.tabGroupMode = "connection";
const pgA = store.createTab("pg-1", "app", "PG 1", "query");
const pgB = store.createTab("pg-1", "app", "PG 2", "query");
const my = store.createTab("mysql-1", "app", "MY 1", "query");
const mainGroup = store.groups[0];
store.groups = [mainGroup, { id: "second-group", tabIds: [], activeTabId: null }];
store.moveTabToGroup(pgB, "second-group");
const mountedTabs = store.tabs.filter((tab) => tab.id === pgA || tab.id === my);
const { app, host } = mountBar(mainGroup.id, mountedTabs, pgA, pinia);
await settle();
// The pg cluster spans main (pgA) and second-group (pgB). Closing the
// group from THIS pane's menu is bar-local: main loses its pg cluster,
// while the same-key cluster in second-group survives untouched.
const pgPill = host.querySelector<HTMLElement>(`[data-tab-id="${pgA}"]`)!;
expect(pgPill).not.toBeNull();
pgPill.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }));
await settle();
const menu = document.body.querySelector<HTMLElement>("[data-dbx-context-menu]")!;
expect(menu).not.toBeNull();
const closeGroupItem = Array.from(menu.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.includes("Close group"));
expect(closeGroupItem).toBeDefined();
closeGroupItem!.click();
await settle();
const remainingIds = store.tabs.map((tab) => tab.id);
expect(remainingIds).not.toContain(pgA);
expect(remainingIds).toEqual(expect.arrayContaining([pgB, my]));
// second-group keeps its tab, so it is not pruned either.
expect(store.groups.map((group) => group.id)).toEqual(expect.arrayContaining(["second-group"]));
expect(store.groups.find((group) => group.id === "second-group")?.tabIds).toEqual([pgB]);
expect(store.groups.find((group) => group.id === mainGroup.id)?.tabIds).toEqual([my]);
app.unmount();
host.remove();
});
it("closing a group spares the pinned cluster that shares its key", async () => {
const store = useQueryStore();
const settings = useSettingsStore();
settings.editorSettings.tabGroupMode = "connection";
const pgA = store.createTab("pg-1", "app", "PG 1", "query");
const pgB = store.createTab("pg-1", "app", "PG 2", "query");
store.togglePinnedTab(pgB);
const mainGroup = store.groups[0];
const { app, host } = mountBar(mainGroup.id, store.tabs.slice(), pgA, pinia);
await settle();
const pgPill = host.querySelector<HTMLElement>(`[data-tab-id="${pgA}"]`)!;
pgPill.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }));
await settle();
const menu = document.body.querySelector<HTMLElement>("[data-dbx-context-menu]")!;
const closeGroupItem = Array.from(menu.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.includes("Close group"));
expect(closeGroupItem).toBeDefined();
closeGroupItem!.click();
await settle();
// The regular cluster is gone; the pinned tab with the same key survives.
const remainingIds = store.tabs.map((tab) => tab.id);
expect(remainingIds).toEqual([pgB]);
app.unmount();
host.remove();
});
it("offers the detach entry only for query and data tabs and emits the tab upward", async () => {
const store = useQueryStore();
const queryId = store.createTab("pg-1", "app", "PG 1", "query");
const mongoId = store.createTab("mongo-1", "app", "MG 1", "mongo");
const mainGroup = store.groups[0];
const detached: string[] = [];
const { app, host } = mountBar(mainGroup.id, store.tabs.slice(), queryId, pinia, { canDetachTabs: true, "onDetach-tab": (tab: { id: string }) => detached.push(tab.id) });
await settle();
const openMenu = async (tabId: string) => {
host.querySelector<HTMLElement>(`[data-tab-id="${tabId}"]`)!.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }));
await settle();
const menu = document.body.querySelector<HTMLElement>("[data-dbx-context-menu]")!;
return Array.from(menu.querySelectorAll<HTMLButtonElement>("button"));
};
// Query tab: the entry exists and routes the whole tab object upward.
const queryItems = await openMenu(queryId);
const detachItem = queryItems.find((button) => button.textContent?.includes("Open in new window"));
expect(detachItem).toBeDefined();
detachItem!.click();
await settle();
expect(detached).toEqual([queryId]);
// Non-query/data tab: the entry is not rendered at all.
const mongoItems = await openMenu(mongoId);
expect(mongoItems.find((button) => button.textContent?.includes("Open in new window"))).toBeUndefined();
app.unmount();
host.remove();
});
});
@@ -0,0 +1,41 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const source = readFileSync(new URL("../EditorGroupTabBar.vue", import.meta.url), "utf8");
const tabMenuSource = readFileSync(new URL("../../../lib/tabs/tabMenu.ts", import.meta.url), "utf8");
describe("EditorGroupTabBar compatibility with AppTabBar", () => {
it("keeps data-tab double-click Zen mode behavior", () => {
expect(source).toContain('"toggle-zen-mode": []');
expect(source).toContain('tab.mode === "data"');
expect(source).toContain('emit("toggle-zen-mode")');
expect(source).toContain("if (tab.id !== props.activeTabId) {");
expect(source).toContain("startRenameTab(tab);");
});
it("only offers Duplicate for query tabs", () => {
expect(tabMenuSource).toContain("visible: options.canRename");
expect(source).toContain("createRenameDuplicateTabItems");
});
it("does not double-activate from the tab item click", () => {
expect(source).toContain("function activateTab(tabId: string) {");
expect(source).toContain('emit("activate-tab", tabId);');
expect(source).not.toContain("queryStore.activateTabInGroup(props.groupId, tabId);");
});
it("cleans up drag listeners on cancel, blur, and unmount", () => {
expect(source).toContain("function cleanupTabDrag(event?: Event)");
expect(source).toContain('window.addEventListener("pointercancel", cleanupTabDrag)');
expect(source).toContain('window.addEventListener("blur", cleanupTabDrag)');
expect(source).toContain("onUnmounted(cleanupTabDrag)");
});
it("validates drag source and target groups before moving", () => {
expect(source).toContain("serializeTabDragPayload");
expect(source).toContain("parseTabDragPayload");
expect(source).toContain("const sourceGroupExists = queryStore.groups.some((group) => group.id === payload.sourceGroupId)");
expect(source).toContain("const targetGroupExists = queryStore.groups.some((group) => group.id === targetGroupId)");
expect(source).toContain("if (!sourceGroupExists || !targetGroupExists) {");
});
});
@@ -0,0 +1,203 @@
// @vitest-environment happy-dom
import { createApp, nextTick } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { createI18n } from "vue-i18n";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/components/ui/button", () => ({
Button: {
name: "ButtonStub",
template: `<button><slot /></button>`,
},
}));
vi.mock("@/components/ui/searchable-select", () => ({
SearchableSelect: {
name: "SearchableSelectStub",
template: `<div />`,
},
}));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: {
name: "TooltipStub",
template: `<span><slot /></span>`,
},
TooltipTrigger: {
name: "TooltipTriggerStub",
template: `<span><slot /></span>`,
},
TooltipContent: {
name: "TooltipContentStub",
template: `<span><slot /></span>`,
},
}));
vi.mock("@/components/ui/TruncatedTextTooltip.vue", () => ({
default: {
name: "TruncatedTextTooltipStub",
template: `<span />`,
},
}));
vi.mock("@/components/icons/DatabaseIcon.vue", () => ({
default: {
name: "DatabaseIconStub",
template: `<span />`,
},
}));
vi.mock("@/components/connection/ConnectionTreeSelect.vue", () => ({
default: {
name: "ConnectionTreeSelectStub",
template: `<div />`,
},
}));
vi.mock("@/components/common/ProductionContextBadge.vue", () => ({
default: {
name: "ProductionContextBadgeStub",
template: `<span />`,
},
}));
import EditorToolbar from "../EditorToolbar.vue";
import { useConnectionStore } from "@/stores/connectionStore";
function createHost(): HTMLDivElement {
const host = document.createElement("div");
document.body.appendChild(host);
return host;
}
describe("EditorToolbar mount contract", () => {
let pinia: ReturnType<typeof createPinia>;
let i18n: ReturnType<typeof createI18n>;
beforeEach(() => {
document.body.innerHTML = "";
pinia = createPinia();
setActivePinia(pinia);
i18n = createI18n({
legacy: false,
locale: "en",
messages: { en: {} },
});
});
it("emits toolbarExecute instead of execute when the run button is clicked", async () => {
const connectionStore = useConnectionStore();
connectionStore.connections = [
{
id: "conn-1",
name: "conn",
db_type: "mysql",
color: "",
} as never,
];
const host = createHost();
const onToolbarExecute = vi.fn();
const app = createApp(EditorToolbar, {
activeTab: {
id: "tab-1",
title: "SQL",
connectionId: "conn-1",
database: "db",
sql: "SELECT 1",
mode: "query",
isExecuting: false,
isCancelling: false,
isExplaining: false,
},
activeConnection: connectionStore.getConfig("conn-1"),
executableSql: "SELECT 1",
explainMode: "explain",
blockDangerousRedisCommands: false,
sqlKeywordCase: "preserve",
databaseRequiredSignal: 0,
autoCommit: true,
txnSessionId: undefined,
txnAutoRolledBack: false,
oracleTxnPossiblyDirty: false,
isOracleManualTransaction: false,
onToolbarExecute,
});
app.use(pinia);
app.use(i18n);
app.mount(host);
await nextTick();
const buttons = host.querySelectorAll("button");
expect(buttons.length).toBeGreaterThan(0);
const runButton = buttons[0];
runButton.dispatchEvent(new MouseEvent("click", { detail: 1, bubbles: true }));
await nextTick();
expect(onToolbarExecute).toHaveBeenCalledTimes(1);
expect(onToolbarExecute.mock.calls[0]?.[0]).toBe("pointer");
app.unmount();
host.remove();
});
it("mounts for a schema-aware connection whose watchEffect reads tier-gated selectors", async () => {
// Regression: the tier-gated showSchemaSelector is read eagerly by the
// schema-loading watchEffect, so the tier refs must be declared before any
// computed that references them (a Postgres connection takes this branch;
// MySQL never did, which is why the TDZ crash only reproduced live).
const connectionStore = useConnectionStore();
connectionStore.connections = [
{
id: "conn-pg",
name: "pg",
db_type: "postgres",
host: "localhost",
port: 5432,
username: "root",
password: "",
} as never,
];
const host = createHost();
const errors: unknown[] = [];
const app = createApp(EditorToolbar, {
activeTab: {
id: "tab-pg",
title: "SQL",
connectionId: "conn-pg",
database: "app",
sql: "SELECT 1",
mode: "query",
isExecuting: false,
isCancelling: false,
isExplaining: false,
},
activeConnection: connectionStore.getConfig("conn-pg"),
executableSql: "SELECT 1",
explainMode: "explain",
blockDangerousRedisCommands: false,
sqlKeywordCase: "preserve",
databaseRequiredSignal: 0,
autoCommit: true,
txnSessionId: undefined,
txnAutoRolledBack: false,
oracleTxnPossiblyDirty: false,
isOracleManualTransaction: false,
});
app.config.errorHandler = (err) => {
errors.push(err);
};
app.use(pinia);
app.use(i18n);
app.mount(host);
await nextTick();
await nextTick();
expect(errors).toEqual([]);
expect(host.querySelector(".app-editor-toolbar")).not.toBeNull();
app.unmount();
host.remove();
});
});
@@ -0,0 +1,101 @@
// @vitest-environment happy-dom
import { createApp, nextTick } from "vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../ContentArea.vue", () => ({
default: {
name: "ContentAreaStub",
props: ["activeTab", "activeConnection", "executableSql", "activeOutputView", "formatSqlRequest", "compressSqlRequest", "selectedSql", "cursorPos", "blockDangerousRedisCommands"],
emits: ["execute", "saveSql"],
template: `<button data-test="emit-execute" @click="$emit('execute', { fullSql: 'SELECT 1', selectedSql: 'SELECT 1', cursorPos: 0, selectionFrom: 0, selectionTo: 8 })">emit</button><button data-test="emit-save" @click="$emit('saveSql', 'tab-1')">save</button>`,
},
}));
import QueryEditorSurface from "../QueryEditorSurface.vue";
function createHost(): HTMLDivElement {
const host = document.createElement("div");
document.body.appendChild(host);
return host;
}
describe("QueryEditorSurface mount contract", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
it("forwards child ContentArea execute events to the parent", async () => {
const host = createHost();
const onExecute = vi.fn();
const app = createApp(QueryEditorSurface, {
activeTab: {
id: "tab-1",
title: "SQL",
connectionId: "conn-1",
database: "db",
sql: "SELECT 1",
mode: "query",
},
activeConnection: undefined,
executableSql: "SELECT 1",
activeOutputView: "result",
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
blockDangerousRedisCommands: false,
autoFocus: false,
onExecute,
});
app.mount(host);
await nextTick();
const button = host.querySelector<HTMLButtonElement>('[data-test="emit-execute"]');
expect(button).not.toBeNull();
button?.click();
await nextTick();
expect(onExecute).toHaveBeenCalledTimes(1);
expect(onExecute.mock.calls[0]?.[0]).toMatchObject({ fullSql: "SELECT 1" });
app.unmount();
host.remove();
});
it("forwards child ContentArea saveSql events with the tab id", async () => {
const host = createHost();
const onSaveSql = vi.fn();
const app = createApp(QueryEditorSurface, {
activeTab: {
id: "tab-1",
title: "SQL",
connectionId: "conn-1",
database: "db",
sql: "SELECT 1",
mode: "query",
},
activeConnection: undefined,
executableSql: "SELECT 1",
activeOutputView: "result",
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
blockDangerousRedisCommands: false,
autoFocus: false,
onSaveSql,
});
app.mount(host);
await nextTick();
host.querySelector<HTMLButtonElement>('[data-test="emit-save"]')?.click();
await nextTick();
expect(onSaveSql).toHaveBeenCalledWith("tab-1");
app.unmount();
host.remove();
});
});
@@ -0,0 +1,100 @@
// @vitest-environment happy-dom
import { createApp, nextTick } from "vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../ContentArea.vue", () => ({
default: {
name: "ContentAreaStub",
props: ["activeTab", "activeConnection", "executableSql", "activeOutputView", "formatSqlRequest", "compressSqlRequest", "selectedSql", "cursorPos", "blockDangerousRedisCommands"],
emits: ["previewStatement", "focusStatement", "reload"],
template: `<div><button data-test="emit-preview" @click="$emit('previewStatement', 'tab-1', { from: 0, to: 8 })">preview</button><button data-test="emit-focus" @click="$emit('focusStatement', 'tab-1', { from: 0, to: 8 })">focus</button><button data-test="emit-reload" @click="$emit('reload', 'SELECT 1')">reload</button></div>`,
},
}));
import QueryResultSurface from "../QueryResultSurface.vue";
function createHost(): HTMLDivElement {
const host = document.createElement("div");
document.body.appendChild(host);
return host;
}
describe("QueryResultSurface mount contract", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
it("forwards preview and focus statement events to the parent", async () => {
const host = createHost();
const onPreviewStatement = vi.fn();
const onFocusStatement = vi.fn();
const app = createApp(QueryResultSurface, {
activeTab: {
id: "tab-1",
title: "SQL",
connectionId: "conn-1",
database: "db",
sql: "SELECT 1",
mode: "query",
},
activeConnection: undefined,
executableSql: "SELECT 1",
activeOutputView: "result",
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
blockDangerousRedisCommands: false,
onPreviewStatement,
onFocusStatement,
});
app.mount(host);
await nextTick();
host.querySelector<HTMLButtonElement>('[data-test="emit-preview"]')?.click();
host.querySelector<HTMLButtonElement>('[data-test="emit-focus"]')?.click();
await nextTick();
expect(onPreviewStatement).toHaveBeenCalledWith("tab-1", { from: 0, to: 8 });
expect(onFocusStatement).toHaveBeenCalledWith("tab-1", { from: 0, to: 8 });
app.unmount();
host.remove();
});
it("forwards generic reload events to the parent", async () => {
const host = createHost();
const onReload = vi.fn();
const app = createApp(QueryResultSurface, {
activeTab: {
id: "tab-1",
title: "SQL",
connectionId: "conn-1",
database: "db",
sql: "SELECT 1",
mode: "query",
},
activeConnection: undefined,
executableSql: "SELECT 1",
activeOutputView: "result",
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
blockDangerousRedisCommands: false,
onReload,
});
app.mount(host);
await nextTick();
host.querySelector<HTMLButtonElement>('[data-test="emit-reload"]')?.click();
await nextTick();
expect(onReload).toHaveBeenCalledWith("SELECT 1");
app.unmount();
host.remove();
});
});
@@ -0,0 +1,461 @@
// @vitest-environment happy-dom
import { createApp, nextTick } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { createI18n } from "vue-i18n";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("splitpanes", () => ({
Splitpanes: {
name: "SplitpanesStub",
template: `<div class="splitpanes-stub"><slot /></div>`,
},
Pane: {
name: "PaneStub",
template: `<div class="pane-stub"><slot /></div>`,
},
}));
const editorPreviewCalls = vi.hoisted(() => [] as Array<{ tabId: string; range: unknown }>);
const editorFocusCalls = vi.hoisted(() => [] as Array<{ tabId: string; range: unknown }>);
const groupHandleModRCalls = vi.hoisted(() => [] as Element[]);
const groupFocusSearchCalls = vi.hoisted(() => [] as Array<Element | null>);
const resultHandleModRCalls = vi.hoisted(() => [] as Element[]);
const resultFocusSearchCalls = vi.hoisted(() => [] as boolean[]);
vi.mock("@/components/layout/EditorGroup.vue", () => ({
default: {
name: "EditorGroupStub",
props: ["groupId", "tabIds", "activeTabId"],
emits: ["execute"],
methods: {
previewStatementRange(tabId: string, range: unknown) {
editorPreviewCalls.push({ tabId, range });
return true;
},
focusStatementRange(tabId: string, range: unknown) {
editorFocusCalls.push({ tabId, range });
return true;
},
handleModRTarget(target: Element) {
groupHandleModRCalls.push(target);
return true;
},
focusSearch(target?: Element | null) {
groupFocusSearchCalls.push(target ?? null);
return true;
},
},
template: `<div data-test="editor-group" :data-group-id="groupId" :data-tab-ids="tabIds.join(',')" :data-active-tab-id="activeTabId"><button data-test="emit-execute" @click="$emit('execute', { fullSql: 'SELECT 1', selectedSql: 'SELECT 1', cursorPos: 0, selectionFrom: 0, selectionTo: 8 })">execute</button></div>`,
},
}));
vi.mock("@/components/layout/EditorToolbar.vue", () => ({
default: {
name: "EditorToolbarStub",
props: ["activeTab"],
template: `<div data-test="group-toolbar" />`,
},
}));
vi.mock("@/components/layout/QueryResultSurface.vue", () => ({
default: {
name: "QueryResultSurfaceStub",
props: ["activeTab"],
emits: ["previewStatement", "focusStatement"],
methods: {
handleModRTarget(target: Element) {
resultHandleModRCalls.push(target);
return true;
},
focusSearch() {
resultFocusSearchCalls.push(true);
return true;
},
},
template: `<div data-test="result-surface">{{ activeTab?.id }}<button data-test="emit-preview" @click="$emit('previewStatement', 'tab-a', { from: 0, to: 8 })">preview</button><button data-test="emit-focus" @click="$emit('focusStatement', 'tab-a', { from: 0, to: 8 })">focus</button></div>`,
},
}));
import SqlEditorWorkspace from "../SqlEditorWorkspace.vue";
import { useQueryStore } from "@/stores/queryStore";
function tab(id: string) {
return {
id,
title: id,
connectionId: "conn-1",
database: "db",
sql: "SELECT 1",
mode: "query",
} as const;
}
function dataTab(id: string) {
return { ...tab(id), mode: "data" };
}
function createHost(): HTMLDivElement {
const host = document.createElement("div");
document.body.appendChild(host);
return host;
}
describe("SqlEditorWorkspace mount contract", () => {
let pinia: ReturnType<typeof createPinia>;
let i18n: ReturnType<typeof createI18n>;
beforeEach(() => {
document.body.innerHTML = "";
editorPreviewCalls.length = 0;
editorFocusCalls.length = 0;
groupHandleModRCalls.length = 0;
groupFocusSearchCalls.length = 0;
resultHandleModRCalls.length = 0;
resultFocusSearchCalls.length = 0;
pinia = createPinia();
setActivePinia(pinia);
i18n = createI18n({
legacy: false,
locale: "en",
messages: { en: {} },
});
});
it("renders one editor group per store group and binds the shared result to the global active tab", async () => {
const store = useQueryStore();
store.tabs = [tab("tab-a"), tab("tab-b")];
store.activeTabId = "tab-a";
store.groups = [
{ id: "g1", tabIds: ["tab-a"], activeTabId: "tab-a" },
{ id: "g2", tabIds: ["tab-b"], activeTabId: "tab-b" },
];
store.focusedGroupId = "g1";
store.orientation = "vertical";
store.sizes = [50, 50];
const host = createHost();
const app = createApp(SqlEditorWorkspace, {
activeTab: tab("tab-a"),
activeConnection: undefined,
executableSql: "SELECT 1",
activeOutputView: "result",
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
blockDangerousRedisCommands: false,
});
app.use(pinia);
app.use(i18n);
app.mount(host);
await nextTick();
const groups = host.querySelectorAll('[data-test="editor-group"]');
expect(groups).toHaveLength(2);
expect(groups[0]?.getAttribute("data-group-id")).toBe("g1");
expect(groups[0]?.getAttribute("data-tab-ids")).toBe("tab-a");
expect(groups[1]?.getAttribute("data-group-id")).toBe("g2");
expect(groups[1]?.getAttribute("data-tab-ids")).toBe("tab-b");
const result = host.querySelector<HTMLElement>('[data-test="result-surface"]');
expect(result?.textContent).toContain("tab-a");
app.unmount();
host.remove();
});
it("forwards editor execute events from EditorGroup to the App listener", async () => {
const store = useQueryStore();
store.tabs = [tab("tab-a")];
store.activeTabId = "tab-a";
store.groups = [{ id: "g1", tabIds: ["tab-a"], activeTabId: "tab-a" }];
store.focusedGroupId = "g1";
store.orientation = "vertical";
store.sizes = [100];
const host = createHost();
const onExecute = vi.fn();
const app = createApp(SqlEditorWorkspace, {
activeTab: tab("tab-a"),
activeConnection: undefined,
executableSql: "SELECT 1",
activeOutputView: "result",
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
blockDangerousRedisCommands: false,
onExecute,
});
app.use(pinia);
app.use(i18n);
app.mount(host);
await nextTick();
host.querySelector<HTMLButtonElement>('[data-test="emit-execute"]')?.click();
await nextTick();
expect(onExecute).toHaveBeenCalledTimes(1);
expect(onExecute.mock.calls[0]?.[0]).toMatchObject({ fullSql: "SELECT 1" });
app.unmount();
host.remove();
});
it("toggles the shared result pane through the exposed workspace method", async () => {
const store = useQueryStore();
store.tabs = [tab("tab-a")];
store.activeTabId = "tab-a";
store.groups = [{ id: "g1", tabIds: ["tab-a"], activeTabId: "tab-a" }];
store.focusedGroupId = "g1";
store.orientation = "vertical";
store.sizes = [100];
const host = createHost();
const app = createApp(SqlEditorWorkspace, {
activeTab: tab("tab-a"),
activeConnection: undefined,
executableSql: "SELECT 1",
activeOutputView: "result",
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
blockDangerousRedisCommands: false,
});
app.use(pinia);
app.use(i18n);
const vm = app.mount(host) as any;
await nextTick();
expect(host.querySelector('[data-test="result-surface"]')).not.toBeNull();
vm.toggleResultsPane();
await nextTick();
// Wait out the leave transition frame before asserting the unmount.
await new Promise((resolve) => setTimeout(resolve, 30));
expect(host.querySelector('[data-test="result-surface"]')).toBeNull();
app.unmount();
host.remove();
});
it("routes result previewStatement events to the owning editor group", async () => {
const store = useQueryStore();
store.tabs = [tab("tab-a")];
store.activeTabId = "tab-a";
store.groups = [{ id: "g1", tabIds: ["tab-a"], activeTabId: "tab-a" }];
store.focusedGroupId = "g1";
store.orientation = "vertical";
store.sizes = [100];
const host = createHost();
const app = createApp(SqlEditorWorkspace, {
activeTab: tab("tab-a"),
activeConnection: undefined,
executableSql: "SELECT 1",
activeOutputView: "result",
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
blockDangerousRedisCommands: false,
});
app.use(pinia);
app.use(i18n);
app.mount(host);
await nextTick();
host.querySelector<HTMLButtonElement>('[data-test="emit-preview"]')?.click();
await nextTick();
expect(editorPreviewCalls).toEqual([{ tabId: "tab-a", range: { from: 0, to: 8 } }]);
app.unmount();
host.remove();
});
it("routes result focusStatement events to the owning editor group", async () => {
const store = useQueryStore();
store.tabs = [tab("tab-a")];
store.activeTabId = "tab-a";
store.groups = [{ id: "g1", tabIds: ["tab-a"], activeTabId: "tab-a" }];
store.focusedGroupId = "g1";
store.orientation = "vertical";
store.sizes = [100];
const host = createHost();
const app = createApp(SqlEditorWorkspace, {
activeTab: tab("tab-a"),
activeConnection: undefined,
executableSql: "SELECT 1",
activeOutputView: "result",
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
blockDangerousRedisCommands: false,
});
app.use(pinia);
app.use(i18n);
app.mount(host);
await nextTick();
host.querySelector<HTMLButtonElement>('[data-test="emit-focus"]')?.click();
await nextTick();
expect(editorFocusCalls).toEqual([{ tabId: "tab-a", range: { from: 0, to: 8 } }]);
app.unmount();
host.remove();
});
it("hides the shared result pane while a non-query tab is active and restores it for query tabs", async () => {
const store = useQueryStore();
store.tabs = [tab("tab-a"), dataTab("tab-data")];
store.activeTabId = "tab-a";
store.groups = [{ id: "g1", tabIds: ["tab-a", "tab-data"], activeTabId: "tab-a" }];
store.focusedGroupId = "g1";
store.orientation = "vertical";
store.sizes = [100];
const host = createHost();
const app = createApp(SqlEditorWorkspace, {
activeTab: tab("tab-a"),
activeConnection: undefined,
executableSql: "SELECT 1",
activeOutputView: "result",
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
blockDangerousRedisCommands: false,
});
app.use(pinia);
app.use(i18n);
app.mount(host);
await nextTick();
expect(host.querySelector('[data-test="result-surface"]')).not.toBeNull();
store.activeTabId = "tab-data";
await nextTick();
// The leave transition keeps the surface in the DOM for one more frame
// before unmounting it (CSS durations collapse to zero in the test env).
await new Promise((resolve) => setTimeout(resolve, 30));
expect(host.querySelector("[data-shared-result-surface]")).toBeNull();
// Outer editor pane + always-mounted result pane (collapsed to size 0)
// + one pane per group.
expect(host.querySelectorAll(".pane-stub")).toHaveLength(3);
store.activeTabId = "tab-a";
await nextTick();
expect(host.querySelector('[data-test="result-surface"]')).not.toBeNull();
app.unmount();
host.remove();
});
it("routes Mod+R grid targets to the shared result surface only while a query tab is active", async () => {
const store = useQueryStore();
store.tabs = [tab("tab-a"), dataTab("tab-data")];
store.activeTabId = "tab-a";
store.groups = [{ id: "g1", tabIds: ["tab-a", "tab-data"], activeTabId: "tab-a" }];
store.focusedGroupId = "g1";
store.orientation = "vertical";
store.sizes = [100];
const host = createHost();
const app = createApp(SqlEditorWorkspace, {
activeTab: tab("tab-a"),
activeConnection: undefined,
executableSql: "SELECT 1",
activeOutputView: "result",
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
blockDangerousRedisCommands: false,
});
app.use(pinia);
app.use(i18n);
const vm = app.mount(host) as any;
await nextTick();
// Query tab: grid targets inside the shared result surface route to it.
const resultGridEl = document.createElement("div");
resultGridEl.setAttribute("data-grid-root", "");
host.querySelector('[data-test="result-surface"]')?.appendChild(resultGridEl);
expect(vm.handleModRTarget(resultGridEl)).toBe(true);
expect(resultHandleModRCalls).toEqual([resultGridEl]);
expect(groupHandleModRCalls).toEqual([]);
// Query tab: the cell-detail editor is portaled to body, so it cannot be
// resolved through the DOM — it still routes to the shared result surface.
const portalEl = document.createElement("div");
portalEl.setAttribute("data-cell-detail-editor-root", "");
document.body.appendChild(portalEl);
expect(vm.handleModRTarget(portalEl)).toBe(true);
expect(resultHandleModRCalls).toEqual([resultGridEl, portalEl]);
// Data tab: the grid and its cell-detail dialog belong to the group.
groupHandleModRCalls.length = 0;
resultHandleModRCalls.length = 0;
store.activeTabId = "tab-data";
await nextTick();
const dataGridEl = document.createElement("div");
dataGridEl.setAttribute("data-grid-root", "");
host.querySelector('[data-test="editor-group"]')?.appendChild(dataGridEl);
expect(vm.handleModRTarget(dataGridEl)).toBe(true);
expect(groupHandleModRCalls).toEqual([dataGridEl]);
expect(vm.handleModRTarget(portalEl)).toBe(true);
expect(groupHandleModRCalls).toEqual([dataGridEl, portalEl]);
expect(resultHandleModRCalls).toEqual([]);
portalEl.remove();
app.unmount();
host.remove();
});
it("routes cell-detail search to the group's grid while a data tab is active", async () => {
const store = useQueryStore();
store.tabs = [dataTab("tab-data")];
store.activeTabId = "tab-data";
store.groups = [{ id: "g1", tabIds: ["tab-data"], activeTabId: "tab-data" }];
store.focusedGroupId = "g1";
store.orientation = "vertical";
store.sizes = [100];
const host = createHost();
const app = createApp(SqlEditorWorkspace, {
activeTab: dataTab("tab-data"),
activeConnection: undefined,
executableSql: "",
activeOutputView: "result",
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
blockDangerousRedisCommands: false,
});
app.use(pinia);
app.use(i18n);
const vm = app.mount(host) as any;
await nextTick();
const portalEl = document.createElement("div");
portalEl.setAttribute("data-cell-detail-editor-root", "");
document.body.appendChild(portalEl);
expect(vm.focusSearch(portalEl)).toBe(true);
expect(groupFocusSearchCalls.length).toBe(1);
expect(resultFocusSearchCalls).toEqual([]);
portalEl.remove();
app.unmount();
host.remove();
});
});
@@ -0,0 +1,102 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const workspaceSource = readFileSync(new URL("../SqlEditorWorkspace.vue", import.meta.url), "utf8");
const groupSource = readFileSync(new URL("../EditorGroup.vue", import.meta.url), "utf8");
const groupTabBarSource = readFileSync(new URL("../EditorGroupTabBar.vue", import.meta.url), "utf8");
const tabMenuSource = readFileSync(new URL("../../../lib/tabs/tabMenu.ts", import.meta.url), "utf8");
const editorSurfaceSource = readFileSync(new URL("../QueryEditorSurface.vue", import.meta.url), "utf8");
const resultSurfaceSource = readFileSync(new URL("../QueryResultSurface.vue", import.meta.url), "utf8");
const contentAreaSource = readFileSync(new URL("../ContentArea.vue", import.meta.url), "utf8");
const surfaceContractSource = readFileSync(new URL("../querySurfaces.ts", import.meta.url), "utf8");
describe("SQL editor workspace single-group tracer bullet", () => {
it("composes splitpanes editor groups and one shared result surface", () => {
expect(workspaceSource).toContain("Splitpanes");
expect(workspaceSource).toContain("EditorGroup");
expect(workspaceSource).toContain("QueryResultSurface");
expect(groupSource).toContain("EditorGroupTabBar");
expect(groupSource).toContain("QueryEditorSurface");
expect(groupSource).toContain("ContentArea");
expect(groupSource).toContain("tabIds");
expect(groupSource).toContain("activeTabId");
});
it("routes query editor and shared result through dedicated surface components", () => {
expect(editorSurfaceSource).toContain("editor-only");
expect(resultSurfaceSource).toContain("result-only");
expect(contentAreaSource).toContain("editorOnly?: boolean");
expect(contentAreaSource).toContain("resultOnly?: boolean");
});
it("keeps AppTabBar as the global special-page bar while regular tabs move to groups", () => {
const tabBarSource = readFileSync(new URL("../AppTabBar.vue", import.meta.url), "utf8");
expect(tabBarSource).not.toContain("hideRegularTabs");
expect(groupTabBarSource).toContain("app-tab-bar");
});
it("makes the shared result area vertically resizable through the workspace splitter", () => {
expect(workspaceSource).toContain("sql-editor-workspace-split");
expect(workspaceSource).toContain("onSharedResultResized");
expect(workspaceSource).toContain("dbx-shared-results-pane-size");
expect(workspaceSource).not.toContain("h-1/3");
});
it("routes shared-result statement navigation to the owning editor surface", () => {
expect(workspaceSource).toContain("@preview-statement");
expect(workspaceSource).toContain("@focus-statement");
expect(workspaceSource).toContain("handlePreviewStatement");
expect(workspaceSource).toContain("handleFocusStatement");
expect(resultSurfaceSource).toContain("previewStatement");
expect(resultSurfaceSource).toContain("focusStatement");
expect(resultSurfaceSource).toContain("previewStatementRange");
expect(resultSurfaceSource).toContain("focusStatementRange");
expect(editorSurfaceSource).toContain("previewStatementRange");
expect(editorSurfaceSource).toContain("focusStatementRange");
expect(groupSource).toContain("previewStatementRange");
expect(groupSource).toContain("focusStatementRange");
expect(surfaceContractSource).toContain("previewStatement: [tabId: string, range: StatementRange | null]");
expect(surfaceContractSource).toContain("focusStatement: [tabId: string, range: StatementRange | null]");
});
it("keeps group tabbar behavior compatible with the legacy tab bar", () => {
expect(workspaceSource).toContain("@toggle-zen-mode");
expect(groupSource).toContain("@toggle-zen-mode");
expect(groupTabBarSource).toContain('"toggle-zen-mode": []');
expect(groupTabBarSource).toContain('tab.mode === "data"');
expect(groupTabBarSource).toContain('emit("toggle-zen-mode")');
expect(tabMenuSource).toContain("visible: options.canRename");
expect(groupTabBarSource).toContain("createRenameDuplicateTabItems");
expect(groupTabBarSource).toContain("cleanupTabDrag");
expect(groupTabBarSource).toContain("onUnmounted(cleanupTabDrag)");
expect(groupTabBarSource).toContain("pointercancel");
});
it("keeps event forwarding and per-group active tab wiring intact", () => {
expect(workspaceSource).toContain('v-bind="editorGroupBindings"');
expect(workspaceSource).toContain('v-bind="resultSurfaceBindings"');
expect(groupSource).toContain('v-bind="surfaceBindings"');
expect(groupSource).toContain("activeTab: activeTab.value!");
expect(editorSurfaceSource).toContain('v-bind="bindings"');
expect(resultSurfaceSource).toContain('v-bind="bindings"');
});
it("animates layout transitions for split groups and the shared result surface", () => {
const workspaceCssSource = readFileSync(new URL("../sqlEditorWorkspace.css", import.meta.url), "utf8");
expect(workspaceSource).toContain('import "./sqlEditorWorkspace.css"');
// Split-created group panes emerge from the divider side; hydration-gated
// so the restored layout never plays load choreography.
expect(workspaceSource).toContain("paneEnterClass");
expect(workspaceSource).toContain("workspace-pane-enter");
expect(workspaceCssSource).toContain("@keyframes dbx-workspace-pane-in");
// The result pane stays mounted and collapses via its size; the surface
// fades through a Vue transition while the splitter hides.
expect(workspaceSource).toContain("resultPaneTargetSize");
expect(workspaceSource).toContain('name="result-surface"');
expect(workspaceSource).toContain("result-pane-collapsed");
expect(workspaceCssSource).toContain(".result-surface-enter-active");
expect(workspaceCssSource).toContain(".result-surface-leave-active");
// Motion degrades to instant under prefers-reduced-motion.
expect(workspaceCssSource).toContain("@media (prefers-reduced-motion: reduce)");
});
});
@@ -0,0 +1,40 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8");
// The editor-content wrapper carries a v-show guard that hides it while the
// settings page or driver store is the active surface. A past merge resolved
// this region by nesting the special surfaces INSIDE that guarded wrapper,
// which display:none-d the settings page exactly when it was active. These
// assertions pin the sibling order so a future merge cannot re-introduce it.
describe("App main content surface structure", () => {
it("hides only the editor workspace via the surface guard, never the special pages", () => {
const guard = 'v-show="!driverStoreActive && !settingsStore.settingsPageActive"';
// One guarded wrapper; the welcome screen's v-else-if reuses the flags in
// its own condition and must not be mistaken for a second guard.
expect(appSource.split(guard).length - 1).toBe(1);
// Source order: special surfaces first, then the guarded editor wrapper.
const markers = ["<DriverStorePage", "<EditorSettingsPage", guard, "<SqlEditorWorkspace", "<WelcomeScreen"];
const indices = markers.map((marker) => appSource.indexOf(marker));
expect(indices.every((index) => index >= 0)).toBe(true);
expect([...indices].sort((a, b) => a - b)).toEqual(indices);
});
it("guards the editor wrapper with the open-tab count and chains the welcome screen to it", () => {
expect(appSource).toContain('<div v-if="queryStore.tabs.length > 0" v-show="!driverStoreActive && !settingsStore.settingsPageActive"');
expect(appSource).toContain('v-else-if="queryStore.tabs.length === 0 && !driverStoreActive && !settingsStore.settingsPageActive"');
});
it("anchors the drag-back hit test on every pane strip and the special-surfaces bar", () => {
const groupBarSource = readFileSync(new URL("../EditorGroupTabBar.vue", import.meta.url), "utf8");
const slimBarSource = readFileSync(new URL("../AppTabBar.vue", import.meta.url), "utf8");
expect(groupBarSource).toContain("data-main-tab-bar");
expect(groupBarSource).toContain("'ring-2 ring-primary ring-inset': detachedDropTarget");
expect(slimBarSource).toContain("data-main-tab-bar");
expect(slimBarSource).toContain("'ring-2 ring-primary ring-inset': detachedDropTarget");
// The split workspace renders several bars; the hit test must union their rects.
expect(appSource).toContain('querySelectorAll<HTMLElement>("[data-main-tab-bar]")');
});
});
@@ -0,0 +1,44 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const contentAreaSource = readFileSync(new URL("../ContentArea.vue", import.meta.url), "utf8");
const databaseBrowserSource = readFileSync(new URL("../../objects/DatabaseBrowser.vue", import.meta.url), "utf8");
const objectBrowserSource = readFileSync(new URL("../../objects/ObjectBrowser.vue", import.meta.url), "utf8");
const overflowMenuSource = readFileSync(new URL("../../../components/ui/ToolbarOverflowMenu.vue", import.meta.url), "utf8");
describe("measured toolbar condensation wiring (non-SQL toolbars)", () => {
it("keeps the overflow trigger a single-layer dropdown (no Tooltip nesting)", () => {
expect(overflowMenuSource).toContain("DropdownMenuTrigger as-child");
expect(overflowMenuSource).not.toContain("TooltipTrigger");
expect(overflowMenuSource).not.toContain("LightTooltip");
});
it("condenses the data-mode header row through measured tiers", () => {
expect(contentAreaSource).toContain("useToolbarOverflow(dataToolbarRef");
// Chips keep a min-width floor so scrollWidth reports real overflow.
expect(contentAreaSource).toContain("inline-flex min-w-12 items-center truncate");
expect(contentAreaSource).toContain('ref="dataToolbarRef"');
expect(contentAreaSource).toContain("showDataColumnsChip");
expect(contentAreaSource).toContain("showDataTableInfoButton");
expect(contentAreaSource).toContain("dataToolbarCompact");
expect(contentAreaSource).toContain('v-if="showDataToolbarOverflow"');
});
it("condenses the database browser header row through measured tiers", () => {
expect(databaseBrowserSource).toContain("useToolbarOverflow(toolbarRef");
expect(databaseBrowserSource).toContain('ref="toolbarRef"');
expect(databaseBrowserSource).toContain("showInlineSortAndView");
expect(databaseBrowserSource).toContain("showConnectionChip");
expect(databaseBrowserSource).toContain('v-if="showToolbarOverflow"');
});
it("condenses the object browser header row through measured tiers", () => {
expect(objectBrowserSource).toContain("useToolbarOverflow(toolbarRef");
expect(objectBrowserSource).toContain('ref="toolbarRef"');
expect(objectBrowserSource).toContain("showInlineSortAndView");
expect(objectBrowserSource).toContain("showInlineCheckboxToggle");
expect(objectBrowserSource).toContain("showInlineObjectFilter");
expect(objectBrowserSource).toContain("showDatabaseChip");
expect(objectBrowserSource).toContain('v-if="showToolbarOverflow"');
});
});
@@ -0,0 +1,531 @@
/*
* Shared tab-bar styles for AppTabBar (special pages) and EditorGroupTabBar
* (per-group tabs). These rules are global CSS instead of scoped styles
* because both components render the same class names and scoped rules in one
* component would never match the other's DOM.
*/
/* 多行平铺模式:覆盖滚动相关样式,让标签换行展示 */
.app-tab-scroll.wrap-mode {
height: auto !important;
overflow: visible !important;
overflow-x: visible !important;
overflow-y: visible !important;
flex-wrap: wrap;
}
/* 父级 strip 容器在包含 wrap-mode 时也需要解除高度约束和裁剪 */
.app-tab-strip:has(.wrap-mode) {
height: auto !important;
overflow: visible !important;
}
/* 标签栏行容器(直接子 div)在包含 wrap-mode 时解除固定高度和裁剪 */
.app-tab-bar > div:has(.wrap-mode) {
height: auto !important;
overflow: visible !important;
}
/* 标签栏本身也解除裁剪,让换行内容自然撑高 */
.app-tab-bar:has(.wrap-mode) {
overflow: visible !important;
}
/* 经典布局 + 多行模式:优化多行标签显示 */
.app-tab-scroll.classic-wrap {
row-gap: 0.25rem;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
align-items: flex-start;
}
/* 经典布局下 h-full 在 height:auto 容器中失效,改为固定高度 */
.app-tab-scroll.classic-wrap > div {
height: 2rem;
}
.app-tab-pill {
background-color: var(--app-tab-background);
}
.app-tab-pill[data-active-tab="false"]:hover {
background-color: var(--app-tab-hover-background, color-mix(in oklch, var(--foreground) 8%, transparent));
}
.dirty-tab-marker {
display: inline-flex;
width: 0.5rem;
height: 0.75rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
color: currentColor;
font-size: 13px;
font-weight: 700;
line-height: 12px;
opacity: 0.9;
transform: translateY(2px);
}
.app-tab-scroll::-webkit-scrollbar {
display: none;
}
.app-tab-scrollbar {
position: absolute;
inset-inline: 0;
top: 0;
z-index: 20;
height: 6px;
cursor: pointer;
opacity: 0;
pointer-events: none;
touch-action: none;
transition: opacity 140ms ease;
}
.app-tab-strip:hover .app-tab-scrollbar,
.app-tab-strip:focus-within .app-tab-scrollbar,
.app-tab-scrollbar--dragging {
opacity: 1;
pointer-events: auto;
}
.app-tab-scrollbar::before {
content: "";
position: absolute;
inset-inline: 0;
top: 0;
height: 2px;
border-radius: 999px;
background: color-mix(in oklch, var(--foreground) 8%, transparent);
}
.app-tab-scrollbar--bottom {
top: auto;
bottom: 0;
}
.app-tab-scrollbar--bottom::before {
top: auto;
bottom: 0;
}
.app-tab-scrollbar__thumb {
position: absolute;
top: 0;
height: 2px;
min-width: 20px;
border-radius: 999px;
background: color-mix(in oklch, var(--foreground) 30%, transparent);
transition:
height 120ms ease,
background-color 120ms ease;
}
.app-tab-scrollbar--bottom .app-tab-scrollbar__thumb {
top: auto;
bottom: 0;
}
.app-tab-scrollbar:hover .app-tab-scrollbar__thumb,
.app-tab-scrollbar--dragging .app-tab-scrollbar__thumb {
height: 5px;
background: color-mix(in oklch, var(--foreground) 52%, transparent);
}
/* ------------------------------------------------------------------ */
/* Semantic tab groups (pill clusters) — ported from upstream AppTabBar */
/* ------------------------------------------------------------------ */
.tab-group-header {
display: inline-flex;
flex: none;
cursor: pointer;
align-items: center;
min-width: 0;
border: 0;
background: transparent;
outline: none;
-webkit-user-select: none;
user-select: none;
transition:
color 150ms ease,
box-shadow 150ms ease;
}
.tab-group-header:focus-visible {
box-shadow: 0 0 0 2px var(--ring);
}
.tab-group-header-content {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 0.25rem;
transition:
color 150ms ease,
background-color 150ms ease,
box-shadow 150ms ease;
}
.tab-group-marker {
width: 0.4rem;
height: 0.4rem;
flex: none;
border-radius: 9999px;
background: var(--tab-group-color);
}
.tab-group-chevron {
width: 0.75rem;
height: 0.75rem;
flex: none;
transition: transform 150ms ease;
}
.tab-group-pin {
width: 0.7rem;
height: 0.7rem;
flex: none;
fill: currentColor;
opacity: 0.7;
}
.tab-group-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tab-group-count {
display: inline-flex;
min-width: 1rem;
height: 1rem;
flex: none;
align-items: center;
justify-content: center;
border-radius: 9999px;
padding-inline: 0.25rem;
background: color-mix(in srgb, var(--tab-group-color) 18%, transparent);
font-size: 0.625rem;
line-height: 1;
}
.app-tab-bar:not(.vertical-tab-layout) .tab-group-header {
position: relative;
height: 100%;
align-self: stretch;
margin-inline: 0.15rem 0.05rem;
padding: 0;
color: var(--tab-group-color);
font-size: 0.6875rem;
font-weight: 650;
line-height: 1;
}
.app-tab-bar:not(.vertical-tab-layout) .tab-group-header-content {
height: 1.625rem;
border-radius: 9999px;
padding-inline: 0.4rem 0.5rem;
background: var(--tab-group-soft);
}
.app-tab-bar:not(.vertical-tab-layout) .tab-group-header:hover .tab-group-header-content,
.app-tab-bar:not(.vertical-tab-layout) .tab-group-header--active .tab-group-header-content {
background: color-mix(in srgb, var(--tab-group-color) 18%, transparent);
}
.app-tab-bar:not(.vertical-tab-layout) .tab-group-header--active .tab-group-header-content {
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tab-group-color) 35%, transparent);
}
.app-tab-bar:not(.vertical-tab-layout) .tab-group-header::after {
content: "";
position: absolute;
z-index: 1;
right: -0.45rem;
bottom: 0;
left: 0.3rem;
height: 2px;
border-radius: 9999px;
background: var(--tab-group-color);
pointer-events: none;
opacity: 0.82;
}
.app-tab-bar:not(.vertical-tab-layout) .tab-group-header--collapsed::after {
right: 0.3rem;
}
.app-tab-bar:not(.vertical-tab-layout)[data-placement="bottom"] .tab-group-header::after {
top: 0;
bottom: auto;
}
.app-tab-bar:not(.vertical-tab-layout) .tab-group-tab {
position: relative;
}
.app-tab-bar:not(.vertical-tab-layout) .tab-group-tab::after {
content: "";
position: absolute;
z-index: 1;
right: -0.2rem;
bottom: 0;
left: -0.2rem;
height: 2px;
border-radius: 9999px;
background: var(--tab-group-color);
pointer-events: none;
opacity: 0.82;
}
.app-tab-bar:not(.vertical-tab-layout)[data-placement="bottom"] .tab-group-tab::after {
top: 0;
bottom: auto;
}
.app-tab-bar:not(.vertical-tab-layout) .tab-group-tab--first::after {
left: 0;
}
.app-tab-bar:not(.vertical-tab-layout) .tab-group-tab--last::after {
right: 0.3rem;
}
.tab-group-color-option,
.tab-group-custom-color {
position: relative;
display: inline-flex;
width: 1.75rem;
height: 1.75rem;
flex: none;
cursor: pointer;
align-items: center;
justify-content: center;
border: 2px solid var(--background);
border-radius: 9999px;
background: var(--tab-group-option-color);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--foreground) 18%, transparent);
outline: none;
transition:
box-shadow 150ms ease,
transform 150ms ease;
}
.tab-group-color-option:hover,
.tab-group-custom-color:hover {
transform: scale(1.08);
}
.tab-group-color-option--selected {
box-shadow:
0 0 0 2px var(--background),
0 0 0 4px var(--tab-group-option-color, var(--tab-group-color));
}
.tab-group-color-option--auto {
color: var(--muted-foreground);
background: var(--muted);
}
.tab-group-custom-color {
overflow: hidden;
background: conic-gradient(#ef4444, #f59e0b, #22c55e, #06b6d4, #3b82f6, #a855f7, #ef4444);
}
.tab-group-custom-color input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
cursor: pointer;
opacity: 0;
}
/* ------------------------------------------------------------------ */
/* Vertical placement (left/right): per-pane sidebar strips (Phase 3) */
/* ------------------------------------------------------------------ */
.vertical-tab-layout {
flex: 0 0 15rem;
}
.vertical-tab-layout .app-tab-strip {
height: auto;
min-height: 0;
width: 100%;
}
.vertical-tab-layout .app-tab-pill {
width: 100%;
min-width: 0 !important;
min-height: 2rem;
height: 2rem !important;
border-right: 0;
background-color: var(--app-tab-background, transparent);
transition:
background-color 150ms ease,
border-color 150ms ease,
box-shadow 150ms ease,
color 150ms ease;
}
.vertical-tab-layout .app-tab-pill[data-active-tab="true"] {
border-color: color-mix(in srgb, var(--tab-group-color, var(--ring)) 18%, transparent);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--tab-group-color, var(--ring)) 13%, transparent),
0 1px 2px rgb(0 0 0 / 0.08),
0 3px 9px color-mix(in srgb, var(--tab-group-color, var(--foreground)) 8%, transparent);
}
.vertical-tab-layout .app-tab-scroll > * {
width: 100%;
flex: none;
}
.vertical-tab-layout--collapsed .app-tab-scroll {
align-items: center;
}
.vertical-tab-layout--collapsed .app-tab-pill {
position: relative;
width: 2.5rem;
min-height: 2.5rem;
height: 2.5rem !important;
justify-content: center;
margin: 0.125rem auto;
padding: 0 !important;
border-radius: 0.625rem;
}
.vertical-tab-layout--collapsed .compact-dirty-tab-marker {
position: absolute;
top: 0.3rem;
right: 0.3rem;
width: 0.35rem;
height: 0.35rem;
border-radius: 9999px;
background: var(--destructive);
box-shadow: 0 0 0 1px var(--background);
}
.vertical-tab-layout.vertical-tab-layout--collapsed .tab-group-header {
width: 2.5rem !important;
height: 1.75rem;
margin: 0.35rem auto 0.1rem;
}
.vertical-tab-layout.vertical-tab-layout--collapsed .tab-group-header-content {
justify-content: center;
padding: 0;
}
.vertical-tab-layout.vertical-tab-layout--collapsed .tab-group-tab {
width: 2.5rem;
margin-inline: auto;
padding: 0 !important;
}
.vertical-tab-layout.vertical-tab-layout--collapsed .tab-group-header::after,
.vertical-tab-layout.vertical-tab-layout--collapsed .tab-group-tab::before,
.vertical-tab-layout.vertical-tab-layout--collapsed .tab-group-tab::after {
display: none;
}
.vertical-tab-layout .tab-group-header {
position: relative;
width: calc(100% - 1rem) !important;
height: 2rem;
align-self: center;
margin: 0.35rem 0.5rem 0.08rem;
padding: 0;
color: var(--foreground);
font-size: 0.75rem;
font-weight: 650;
line-height: 1rem;
}
.vertical-tab-layout .tab-group-header-content {
width: 100%;
height: 100%;
border-radius: 0.5rem;
padding-inline: 0.55rem;
}
.vertical-tab-layout .tab-group-header:hover .tab-group-header-content,
.vertical-tab-layout .tab-group-header--active .tab-group-header-content {
background: var(--tab-group-soft);
}
.vertical-tab-layout .tab-group-header--active {
color: var(--tab-group-color);
}
.vertical-tab-layout .tab-group-marker {
width: 0.22rem;
height: 1rem;
border-radius: 9999px;
}
.vertical-tab-layout .tab-group-header:not(.tab-group-header--collapsed)::after {
content: "";
position: absolute;
z-index: 1;
top: 1rem;
bottom: -1.08rem;
left: 0.66rem;
width: 0.7rem;
border-bottom: 1px solid var(--tab-group-rail);
border-left: 1px solid var(--tab-group-rail);
border-bottom-left-radius: 0.1875rem;
pointer-events: none;
}
.vertical-tab-layout .tab-group-header:not(.tab-group-header--collapsed) .tab-group-marker {
position: relative;
z-index: 2;
}
.vertical-tab-layout .tab-group-tab {
position: relative;
width: calc(100% - 3rem);
margin-inline: 2.5rem 0.5rem;
padding-inline-start: 0.65rem !important;
border-color: transparent;
}
.vertical-tab-layout .tab-group-tab::before {
content: "";
position: absolute;
top: -0.25rem;
bottom: -0.25rem;
left: -0.65rem;
width: 1px;
border-radius: 9999px;
background: var(--tab-group-rail);
pointer-events: none;
}
.vertical-tab-layout .tab-group-tab--first::before {
top: 50%;
}
.vertical-tab-layout .tab-group-tab--last::before {
bottom: 50%;
border-radius: 9999px 9999px 0 0;
}
.vertical-tab-layout .tab-group-tab::after {
content: "";
position: absolute;
top: calc(50% - 0.5px);
left: -0.65rem;
width: 0.65rem;
height: 1px;
border-radius: 0 9999px 9999px 0;
background: var(--tab-group-rail);
pointer-events: none;
}
@@ -0,0 +1,74 @@
import type { InjectionKey, Ref } from "vue";
/**
* App-level orchestration behind the per-group SQL editor toolbar.
*
* The toolbar renders inside each editor group, but execution, dialogs, and
* format/compress requests stay App-owned. The group focuses itself on
* pointerdown/focusin before any toolbar event fires, so handlers may resolve
* the acting tab from the explicit tabId argument (never re-reading the global
* active tab after an await).
*/
export interface EditorToolbarActions {
explainMode: Ref<"explain" | "autotrace">;
blockDangerousRedisCommands: Ref<boolean>;
/** Highlights the database selector of the tab that needs a database choice. */
databaseRequiredSignalFor(tabId: string): number;
/** Captures the acting editor's execution snapshot before a toolbar click. */
captureExecutionSnapshot(): void;
toolbarExecute(source: "pointer" | "keyboard"): void;
cancelExecution(tabId: string): void;
explain(tabId: string): void;
formatSql(tabId: string): void;
compressSql(tabId: string): void;
toggleSqlKeywordCase(): void;
saveSql(tabId: string): void;
openSqlFile(): void;
importResultArchive(): void;
pasteSqlInCondition(): void;
multiExecute(): void;
previewChanges(tabId: string): void;
changeConnection(tabId: string, connectionId: string): void;
changeCatalog(tabId: string, catalog: string | undefined, database: string): void;
changeDatabase(tabId: string, database: string): void;
changeSchema(tabId: string, schema: string | undefined): void;
setDefaultDatabase(tabId: string): void;
clearDefaultDatabase(tabId: string): void;
}
export const EDITOR_TOOLBAR_ACTIONS: InjectionKey<EditorToolbarActions> = Symbol("dbx:editor-toolbar-actions");
/**
* No-op fallback so a group mounted outside the workspace (tests, future
* hosts) renders a toolbar that does nothing instead of crashing. Production
* always provides real actions from App.
*/
export function createNoopEditorToolbarActions(): EditorToolbarActions {
const noop = () => undefined;
const mode = { value: "explain" } as Ref<"explain" | "autotrace">;
const flag = { value: true } as Ref<boolean>;
return {
explainMode: mode,
blockDangerousRedisCommands: flag,
databaseRequiredSignalFor: () => 0,
captureExecutionSnapshot: noop,
toolbarExecute: noop,
cancelExecution: noop,
explain: noop,
formatSql: noop,
compressSql: noop,
toggleSqlKeywordCase: noop,
saveSql: noop,
openSqlFile: noop,
importResultArchive: noop,
pasteSqlInCondition: noop,
multiExecute: noop,
previewChanges: noop,
changeConnection: noop,
changeCatalog: noop,
changeDatabase: noop,
changeSchema: noop,
setDefaultDatabase: noop,
clearDefaultDatabase: noop,
};
}
@@ -0,0 +1,98 @@
import type { ConnectionConfig, ObjectBrowserViewport, QueryTab } from "@/types/database";
import type { DataGridReloadIntent } from "@/lib/dataGrid/dataGridToolbar";
import type { DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
import type { SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
import type { SqlExecutionOverride, SqlExecutionSnapshot } from "@/lib/sql/sqlExecutionTarget";
export interface StatementRange {
from: number;
to: number;
}
export interface QueryEditorSurfaceHandle {
focusSearch(target?: Element | null): boolean;
openGoToColumn(): boolean;
refreshData(target?: Element | null): boolean;
toggleResultsPane(): boolean;
refreshQueryEditorCompletionCache(): boolean;
handleModRTarget(target: Element): boolean;
requestQueryEditorExecute(): boolean;
captureQueryEditorExecutionSnapshot(): SqlExecutionSnapshot | undefined;
requestQueryEditorExecuteInNewResultTab(): boolean;
requestQueryEditorPreviewChanges(stackSql?: string): Promise<boolean>;
shouldBlockQueryEditorExecutionShortcut(event: KeyboardEvent): boolean;
cancelQueryEditorExecutionViewport(requestId: number): boolean;
acceptQueryEditorExecutionViewport(requestId: number): boolean;
pasteClipboardAsSqlInCondition(): Promise<boolean>;
applyTableStructureChanges(): Promise<boolean>;
insertRedisCommand(command: string): Promise<boolean>;
executeRedisCommand(command: string): Promise<boolean>;
previewStatementRange(range: StatementRange | null): boolean;
focusStatementRange(range: StatementRange | null): boolean;
}
export interface QueryResultSurfaceHandle {
focusSearch(target?: Element | null): boolean;
refreshData(target?: Element | null): boolean;
toggleResultsPane(): boolean;
handleModRTarget(target: Element): boolean;
previewStatementRange(range: StatementRange | null): boolean;
focusStatementRange(range: StatementRange | null): boolean;
}
export interface ContentAreaSurfaceProps {
activeTab: QueryTab;
activeConnection?: ConnectionConfig;
executableSql: string;
activeOutputView: "result" | "summary" | "explain" | "chart" | "messages";
formatSqlRequest: { id: number; tabId: string } | null;
compressSqlRequest: { id: number; tabId: string } | null;
selectedSql: string;
cursorPos: number;
blockDangerousRedisCommands: boolean;
zenMode?: boolean;
}
/**
* Event contract between content surfaces and the workspace/App layer.
* Every operation that can outlive a focus change — execution, cancellation,
* result paging, AI hand-off, formatting, view switches — carries the tabId
* of the tab that originated it, so consumers never have to re-read the
* global active tab after an await (see redevelopment guide §6.1).
*/
export interface ContentAreaSurfaceEmits {
"update:activeOutputView": [tabId: string, value: "result" | "summary" | "explain" | "chart" | "messages"];
fixWithAi: [tabId: string, errorMessage: string];
sendSelectionToAi: [tabId: string, sql: string];
previewChangesAvailable: [tabId: string, value: boolean];
execute: [tabId: string, sqlOverride?: SqlExecutionOverride];
executeInNewResultTab: [tabId: string, sqlOverride?: SqlExecutionOverride];
saveSql: [tabId: string];
cancel: [tabId: string];
explain: [tabId: string];
editorUpdate: [tabId: string, value: string];
editorSelectionChange: [tabId: string, value: string];
editorCursorChange: [tabId: string, pos: number];
editorViewportChange: [tabId: string, viewport: { scrollTop: number; scrollLeft: number }];
editorSelectionStateChange: [tabId: string, selection: { anchor: number; head: number }];
formatError: [tabId: string];
reload: [tabId: string, sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent];
paginate: [tabId: string, offset: number, limit: number, whereInput?: string, orderBy?: string];
sort: [tabId: string, column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode?: DataGridSortMode];
executeSql: [tabId: string, sql: string];
clickTable: [tabId: string, target: SqlObjectNavigationTarget];
viewTableData: [tabId: string, target: SqlObjectNavigationTarget];
viewTableDdl: [tabId: string, target: SqlObjectNavigationTarget];
editTableStructure: [tabId: string, target: SqlObjectNavigationTarget];
openObjectSource: [tabId: string, target: SqlObjectNavigationTarget, initialEditing: boolean];
openObjectTable: [tabId: string, target: { tableName: string; schema?: string; tableType?: string; catalog?: string }];
objectSchemaChange: [tabId: string, schema: string | undefined];
objectBrowserViewportChange: [tabId: string, viewport: ObjectBrowserViewport];
structureEditorSaved: [tabId: string, commentChanged: boolean];
structureEditorClose: [tabId: string];
previewStatement: [tabId: string, range: StatementRange | null];
focusStatement: [tabId: string, range: StatementRange | null];
openSettings: [initialTab?: string, initialSection?: string];
openConnectionSettings: [connectionId: string, initialTab: "advanced"];
toggleZenMode: [];
}
@@ -0,0 +1,87 @@
/*
* Motion for layout transitions inside the SQL editor workspace.
*
* Pane SIZE changes (split, result-pane collapse/expand) are smoothed by
* splitpanes' stock pane transition, which globals.css kills for horizontal
* splitpanes — re-enabled here for this workspace only (drag still disables
* it via the higher-specificity rule below). The entrance effects cover the
* content that would otherwise pop in: groups created by a split, and the
* shared result surface re-mounting on a query tab.
*
* Product motion rules: transform/opacity only, ease-out-quart, 150-250ms,
* no load choreography (groups present at first render never animate), and
* everything degrades to instant under prefers-reduced-motion.
*/
.sql-editor-workspace {
--sql-workspace-ease-out: cubic-bezier(0.25, 1, 0.5, 1);
}
/* Pane size transitions: restore splitpanes' stock behavior inside the
workspace (globals.css disables it for all horizontal splitpanes). */
.sql-editor-workspace .splitpanes--ready .splitpanes__pane {
transition: width 0.2s ease-out, height 0.2s ease-out !important;
}
.sql-editor-workspace .splitpanes--ready.splitpanes--dragging .splitpanes__pane {
transition: none !important;
}
/* A group pane created after the workspace is interactive emerges from its
owner's side (the divider). No fill mode: the transform is released once
the animation ends so the pane never becomes a containing block. */
.sql-editor-workspace .workspace-pane-enter {
animation: dbx-workspace-pane-in 220ms var(--sql-workspace-ease-out);
}
.sql-editor-workspace .workspace-pane-enter--from-left {
--dbx-pane-enter-x: -12px;
}
.sql-editor-workspace .workspace-pane-enter--from-top {
--dbx-pane-enter-y: -12px;
}
@keyframes dbx-workspace-pane-in {
from {
opacity: 0;
transform: translate(var(--dbx-pane-enter-x, 0), var(--dbx-pane-enter-y, 0));
}
}
/* Shared result surface: rises into place while its pane expands, and fades
out faster than the pane collapses. */
.sql-editor-workspace .result-surface-enter-active {
transition: opacity 180ms var(--sql-workspace-ease-out), transform 180ms var(--sql-workspace-ease-out);
}
.sql-editor-workspace .result-surface-enter-from {
opacity: 0;
transform: translateY(10px);
}
.sql-editor-workspace .result-surface-leave-active {
transition: opacity 120ms var(--sql-workspace-ease-out);
}
.sql-editor-workspace .result-surface-leave-to {
opacity: 0;
}
/* The splitter between the editor and result panes stays in the splitpanes
flow even when the result pane is collapsed; fade it out and disable it so
the closed state is visually clean and cannot be dragged open into an
empty pane. */
.sql-editor-workspace-split > .splitpanes__splitter {
transition: opacity 150ms var(--sql-workspace-ease-out);
}
.sql-editor-workspace-split.result-pane-collapsed > .splitpanes__splitter {
opacity: 0;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.sql-editor-workspace .workspace-pane-enter {
animation: none;
}
.sql-editor-workspace .result-surface-enter-active,
.sql-editor-workspace .result-surface-leave-active {
transition: none;
}
.sql-editor-workspace .splitpanes--ready .splitpanes__pane {
transition: none !important;
}
}
@@ -1,9 +1,12 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { ArrowDown, ArrowUp, Database, GripVertical, LayoutGrid, List, Loader2, RefreshCw, Search, X } from "@lucide/vue";
import { ArrowDown, ArrowUp, Check, Database, GripVertical, LayoutGrid, List, Loader2, RefreshCw, Search, X } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import ToolbarOverflowMenu from "@/components/ui/ToolbarOverflowMenu.vue";
import { useToolbarOverflow } from "@/composables/useToolbarOverflow";
import * as api from "@/lib/backend/api";
import { filterDatabaseNamesForConnection } from "@/lib/database/visibleDatabases";
import { formatObjectBrowserBytes, formatObjectBrowserTimestamp } from "@/lib/table/objectBrowserRows";
@@ -57,6 +60,14 @@ const hasComment = computed(() => rows.value.some((row) => !!row.comment?.trim()
const hasDefaultCharset = computed(() => rows.value.some((row) => !!row.defaultCharset?.trim()));
const hasDefaultCollation = computed(() => rows.value.some((row) => !!row.defaultCollation?.trim()));
const isListView = computed(() => settingsStore.editorSettings.objectBrowserViewMode !== "grid");
// Measured condensation for the header row: tier 1 moves the sort/view
// controls into the overflow menu, tier 2 drops the connection chip (the tab
// title already carries it). See useToolbarOverflow for the tier contract.
const toolbarRef = ref<HTMLElement | null>(null);
const { tier: toolbarTier } = useToolbarOverflow(toolbarRef, [() => props.connection.id]);
const showToolbarOverflow = computed(() => toolbarTier.value >= 1);
const showConnectionChip = computed(() => toolbarTier.value < 2);
const showInlineSortAndView = computed(() => toolbarTier.value < 1);
const columns = computed<DatabaseBrowserColumnKey[]>(() => {
const next: DatabaseBrowserColumnKey[] = ["name"];
@@ -238,18 +249,18 @@ defineExpose({ focusSearch, refresh });
<template>
<section class="flex h-full min-h-0 min-w-0 flex-col bg-background">
<div class="flex h-10 shrink-0 items-center gap-2 border-b px-3">
<span class="inline-flex max-w-[14rem] min-w-0 items-center truncate rounded border border-border bg-muted/50 px-2 py-0.5 text-xs font-medium" :title="connection.name">
<div ref="toolbarRef" class="flex h-10 shrink-0 items-center gap-2 overflow-hidden border-b px-3">
<span v-if="showConnectionChip" class="inline-flex max-w-[14rem] min-w-12 items-center truncate rounded border border-border bg-muted/50 px-2 py-0.5 text-xs font-medium" :title="connection.name">
{{ connection.name }}
</span>
<div class="relative min-w-0 flex-1">
<div class="relative min-w-24 flex-1">
<Search class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input ref="searchInput" v-model="search" class="h-7 pl-8 pr-6 text-xs" :placeholder="t('databaseBrowser.search')" />
<button v-if="search" type="button" class="absolute right-1.5 top-1/2 flex h-4 w-4 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground" :aria-label="t('common.clear')" @click="clearDatabaseSearch">
<X class="h-3 w-3" />
</button>
</div>
<div class="flex h-7 shrink-0 items-center rounded border bg-muted/20 p-0.5">
<div v-if="showInlineSortAndView" class="flex h-7 shrink-0 items-center rounded border bg-muted/20 p-0.5">
<select
class="h-6 cursor-pointer appearance-none rounded-sm bg-transparent px-1.5 text-xs text-muted-foreground outline-none hover:text-foreground focus:text-foreground"
:value="sortKey"
@@ -263,7 +274,7 @@ defineExpose({ focusSearch, refresh });
<ArrowDown v-else class="h-3 w-3" />
</button>
</div>
<div class="flex h-7 shrink-0 items-center rounded border bg-muted/20 p-0.5">
<div v-if="showInlineSortAndView" class="flex h-7 shrink-0 items-center rounded border bg-muted/20 p-0.5">
<button type="button" class="flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground" :class="{ 'bg-background text-foreground shadow-sm': isListView }" :title="t('objects.viewList')" @click="setViewMode('list')">
<List class="h-3.5 w-3.5" />
</button>
@@ -274,6 +285,34 @@ defineExpose({ focusSearch, refresh });
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('contextMenu.refreshTab')" :disabled="loading" @click="refresh">
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loading }" />
</Button>
<ToolbarOverflowMenu v-if="showToolbarOverflow" :label="t('toolbar.moreActions')" button-class="h-7 w-7">
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<ArrowDown class="h-3.5 w-3.5" />
{{ t("objects.sortBy") }}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem v-for="key in sortKeyOptions" :key="key" @select="toggleSort(key)">
<Check v-if="sortKey === key" class="h-3.5 w-3.5" />
{{ columnLabel(key) }}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem @select="sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'">
<ArrowUp v-if="sortDirection === 'asc'" class="h-3.5 w-3.5" />
<ArrowDown v-else class="h-3.5 w-3.5" />
{{ sortDirection === "asc" ? t("objects.sortDesc") : t("objects.sortAsc") }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @select="setViewMode('list')">
<List class="h-3.5 w-3.5" />
{{ t("objects.viewList") }}
</DropdownMenuItem>
<DropdownMenuItem @select="setViewMode('grid')">
<LayoutGrid class="h-3.5 w-3.5" />
{{ t("objects.viewGrid") }}
</DropdownMenuItem>
</ToolbarOverflowMenu>
</div>
<div v-if="loading && rows.length === 0" class="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground"><Loader2 class="h-4 w-4 animate-spin" />{{ t("objects.loading") }}</div>
@@ -8,6 +8,7 @@ import {
ArrowRightLeft,
ArrowUp,
Braces,
Check,
CheckSquare,
Clock,
Clipboard,
@@ -56,6 +57,9 @@ import { translateBackendError } from "@/i18n/backend-errors";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { DropdownMenuCheckboxItem, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from "@/components/ui/dropdown-menu";
import ToolbarOverflowMenu from "@/components/ui/ToolbarOverflowMenu.vue";
import { useToolbarOverflow } from "@/composables/useToolbarOverflow";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDialog.vue";
@@ -388,6 +392,17 @@ const objectFilters = computed<ObjectFilter[]>(() =>
.map(([filter]) => filter),
);
const showObjectFilter = computed(() => objectFilters.value.length > 2);
// Measured condensation for the header row: tier 1 moves the sort/view/checkbox
// controls into the overflow menu, tier 2 additionally moves the object type
// filter there and drops the database chip. See useToolbarOverflow for the
// tier contract.
const toolbarRef = ref<HTMLElement | null>(null);
const { tier: toolbarTier } = useToolbarOverflow(toolbarRef, [() => props.database, () => selectedSchema.value, () => needsSchema.value, () => showObjectFilter.value]);
const showToolbarOverflow = computed(() => toolbarTier.value >= 1);
const showInlineSortAndView = computed(() => toolbarTier.value < 1);
const showInlineCheckboxToggle = computed(() => toolbarTier.value < 1);
const showInlineObjectFilter = computed(() => toolbarTier.value < 2);
const showDatabaseChip = computed(() => toolbarTier.value < 2);
const hasCreatedAt = computed(() => rows.value.some((row) => row.created_at?.trim()));
const hasUpdatedAt = computed(() => rows.value.some((row) => row.updated_at?.trim()));
const hasAnyComment = computed(() => rows.value.some((row) => row.comment?.trim()));
@@ -3194,16 +3209,16 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<template>
<div ref="rootRef" data-object-browser-root class="flex h-full min-h-0 min-w-0 flex-col bg-background outline-none" tabindex="0" @keydown="onObjectBrowserKeydown">
<div v-if="!isEventEditor" class="flex h-10 shrink-0 items-center gap-2 border-b px-3">
<div class="flex min-w-0 items-center gap-2">
<div v-if="!isEventEditor" ref="toolbarRef" class="flex h-10 shrink-0 items-center gap-2 overflow-hidden border-b px-3">
<div class="flex min-w-12 items-center gap-2">
<span class="inline-flex max-w-[14rem] min-w-0 items-center rounded border border-border bg-muted/50 px-2 py-0.5 text-xs font-medium truncate" :title="selectedSchema || props.database">
{{ selectedSchema || props.database }}
</span>
<span v-if="selectedSchema" class="inline-flex max-w-[14rem] min-w-0 items-center rounded border border-border bg-muted/30 px-2 py-0.5 text-xs text-muted-foreground truncate" :title="props.database">
<span v-if="selectedSchema && showDatabaseChip" class="inline-flex max-w-[14rem] min-w-0 items-center rounded border border-border bg-muted/30 px-2 py-0.5 text-xs text-muted-foreground truncate" :title="props.database">
{{ props.database }}
</span>
</div>
<div class="flex min-w-0 flex-1 items-center gap-2">
<div class="flex min-w-24 flex-1 items-center gap-2">
<div class="relative min-w-0 flex-1">
<Search class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input v-model="search" data-object-search-input class="h-7 pl-8 pr-6 text-xs" :placeholder="isMongodb ? t('objects.searchCollections') : t('objects.search')" @keydown="onSearchKeydown" />
@@ -3211,7 +3226,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<X class="h-3 w-3" />
</button>
</div>
<div v-if="showObjectFilter" class="flex h-7 shrink-0 items-center rounded border bg-muted/20 p-0.5">
<div v-if="showObjectFilter && showInlineObjectFilter" class="flex h-7 shrink-0 items-center rounded border bg-muted/20 p-0.5">
<button
v-for="filter in objectFilters"
:key="filter"
@@ -3243,7 +3258,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
@update:model-value="onSchemaChange"
/>
<!-- Sort selector -->
<div class="flex h-7 shrink-0 items-center rounded border bg-muted/20 p-0.5">
<div v-if="showInlineSortAndView" class="flex h-7 shrink-0 items-center rounded border bg-muted/20 p-0.5">
<select
class="h-6 cursor-pointer appearance-none rounded-sm bg-transparent px-1.5 text-xs text-muted-foreground outline-none hover:text-foreground focus:text-foreground"
:value="sortKey"
@@ -3259,7 +3274,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<ArrowDown v-else class="h-3 w-3" />
</button>
</div>
<div class="flex h-7 shrink-0 items-center rounded border bg-muted/20 p-0.5">
<div v-if="showInlineSortAndView" class="flex h-7 shrink-0 items-center rounded border bg-muted/20 p-0.5">
<button type="button" class="flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground" :class="{ 'bg-background text-foreground shadow-sm': isListView }" :title="t('objects.viewList')" @click="setViewMode('list')">
<List class="h-3.5 w-3.5" />
</button>
@@ -3267,7 +3282,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<LayoutGrid class="h-3.5 w-3.5" />
</button>
</div>
<Button variant="ghost" size="icon" class="h-7 w-7" :class="{ 'text-primary': settingsStore.editorSettings.objectBrowserShowCheckbox }" :title="t('objects.toggleCheckbox')" @click="toggleCheckboxColumn">
<Button v-if="showInlineCheckboxToggle" variant="ghost" size="icon" class="h-7 w-7" :class="{ 'text-primary': settingsStore.editorSettings.objectBrowserShowCheckbox }" :title="t('objects.toggleCheckbox')" @click="toggleCheckboxColumn">
<CheckSquare v-if="settingsStore.editorSettings.objectBrowserShowCheckbox" class="h-3.5 w-3.5" />
<Square v-else class="h-3.5 w-3.5" />
</Button>
@@ -3278,6 +3293,49 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<Clipboard class="mr-1.5 h-3.5 w-3.5" />
{{ t("objects.pasteTableSelected") }}
</Button>
<ToolbarOverflowMenu v-if="showToolbarOverflow" :label="t('toolbar.moreActions')" button-class="h-7 w-7">
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<ArrowDown class="h-3.5 w-3.5" />
{{ t("objects.sortBy") }}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem v-for="key in sortKeyOptions" :key="key" @select="onSortKeyChange(key)">
<Check v-if="sortKey === key" class="h-3.5 w-3.5" />
{{ sortKeyLabel(key) }}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem @select="sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'">
<ArrowUp v-if="sortDirection === 'asc'" class="h-3.5 w-3.5" />
<ArrowDown v-else class="h-3.5 w-3.5" />
{{ sortDirection === "asc" ? t("objects.sortDesc") : t("objects.sortAsc") }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @select="setViewMode('list')">
<List class="h-3.5 w-3.5" />
{{ t("objects.viewList") }}
</DropdownMenuItem>
<DropdownMenuItem @select="setViewMode('grid')">
<LayoutGrid class="h-3.5 w-3.5" />
{{ t("objects.viewGrid") }}
</DropdownMenuItem>
<DropdownMenuCheckboxItem :model-value="settingsStore.editorSettings.objectBrowserShowCheckbox" @select.prevent @update:model-value="toggleCheckboxColumn()">{{ t("objects.toggleCheckbox") }}</DropdownMenuCheckboxItem>
<template v-if="showObjectFilter && toolbarTier >= 2">
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
v-for="filter in objectFilters"
:key="filter"
:model-value="objectFilter === filter"
@select.prevent
@update:model-value="
userHasSelectedFilter = true;
objectFilter = filter;
"
>{{ filterLabel(filter) }}</DropdownMenuCheckboxItem
>
</template>
</ToolbarOverflowMenu>
</div>
<div v-if="selectedTableCount > 0" class="flex h-9 shrink-0 items-center gap-2 overflow-x-auto border-b bg-muted/30 px-3 text-xs">
<div class="min-w-0 flex-1 truncate text-muted-foreground">
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { MoreHorizontal } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
/**
* Shared "⋯" overflow trigger for measured-condensation toolbars
* (useToolbarOverflow consumers). The trigger is a single-layer
* DropdownMenuTrigger — deliberately NOT wrapped in a Tooltip, which breaks
* the pointer event chain in WKWebView (see EditorToolbar's overflow menu).
*/
withDefaults(
defineProps<{
/** Accessible name / title for the trigger (already localized). */
label: string;
align?: "start" | "center" | "end";
/** Extra classes for the trigger button, e.g. a different height. */
buttonClass?: string;
contentClass?: string;
}>(),
{
align: "end",
buttonClass: "",
contentClass: "w-56",
},
);
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground" :class="buttonClass" :aria-label="label" :title="label">
<MoreHorizontal class="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent :align="align" :class="contentClass">
<slot />
</DropdownMenuContent>
</DropdownMenu>
</template>