mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(editor): add sql editor split panes with shared results
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
# feat/sql-editor-split 分支 PR 前完整 Review 与修复计划
|
||||
|
||||
## Review 结论(基于两路并行审查,所有发现均已在源码逐行核实)
|
||||
|
||||
分支整体架构健康:两轮评审(第一轮 R1-R12、第二轮 SP/ST 编号)的修复痕迹全部可确认(registerOpenTab 原子化、事件契约 33 个事件全带 tabId、appTabBar.css 共享样式、恢复顺序修正、拖拽 owner 校验、四组 disabled、AppTabBar 单生产路径)。**发现 1 个高优先级功能 bug、4 个中优先级缺陷、若干低优先级问题,全部有明确修复方案。**
|
||||
|
||||
## 审查发现(按严重度)
|
||||
|
||||
### 🔴 P0-1 工具栏 EXPLAIN 按钮把 tab UUID 当 SQL 执行(已核实:App.vue:503)
|
||||
`explain: (tabId: string) => tryExplain(tabId)` — `tryExplain` 第一参数是 `sqlOverride: string | snapshot`,tabId 类型合法地滑过了编译器,点击任何一组的"执行计划"按钮会把 UUID 当 SQL 去 EXPLAIN,必然后端报错。对照正确写法已存在于 App.vue:3140(`tryExplain(undefined, { tabId })`)。**修复:改为 `tryExplain(undefined, { tabId })`,并补一条 EditorGroup 挂载测试接真实 actions 断言 explain 调用形状(现有测试 mock 了 actions 只断言收到 tabId,是盲区)。**
|
||||
|
||||
### 🟡 P1-1 showDirtyTabCloseConfirm 未同步群组(queryStore.ts:2937)
|
||||
脏 tab 关闭确认直接写 `activeTabId.value = tab.id`,不切 focusedGroupId、不改组 active — 确认期间共享结果区跟随脏 tab 而组编辑器显示另一个 tab;确认关闭后焦点跳回原组而非脏 tab 所在组。**修复:改为 `activateTab(tab.id)`。**
|
||||
|
||||
### 🟡 P1-2 closeTab 空组剪除不修 sizes(queryStore.ts:3188-3194)
|
||||
对比 removeTabsFromGroups/moveTabToGroup 都走 pruneEmptyGroups→repairGroupSizes,closeTab 的内联剪除漏了 repairGroupSizes:2 组 [50,50] 关掉一组最后一个 tab 后 sizes 仍 [50,50],单 Pane 可能只占半宽直到手动拖动或重启。**修复:剪除后调 `repairGroupSizes()`(或将内联逻辑改调 pruneEmptyGroups)。**
|
||||
|
||||
### 🟡 P1-3 他组卸载会杀死进行中的拖拽(EditorGroupTabBar.vue:507/408)
|
||||
模块级共享拖拽会话,但每个实例的 `onUnmounted(cleanupTabDrag)` 无条件清空共享状态 — 拖拽进行中用 Ctrl+W 关掉另一组最后一个 tab,该组卸载会把源栏的 ghost/状态一起清掉。**修复:cleanupTabDrag 增加 `groupTabDrag.sourceGroupId === props.groupId` 守卫(仅拖拽源实例清共享态),监听移除仍无条件执行。**
|
||||
|
||||
### 🟡 P1-4 AI 自动执行 danger 分支缺 pendingDangerTabId(App.vue:2333-2335)
|
||||
dangerSql 直接置值打开对话框但未设 `pendingDangerTabId`(本分支新增机制),确认时 `onDangerConfirm` 回退到"确认时刻"的全局 active tab;模态期间 Ctrl+数字仍可切 tab,存在目标漂移窗口 — 与同函数 :2330 已正确传 tabId 的 auto_execute 分支不一致。**修复:补 `pendingDangerSql.value = sql` 同处置 `pendingDangerTabId` 的途径 — 由于该状态在 useSqlExecution 内部,方案为给 App 暴露的路径统一改走 `tryExecute(sql, { tabId })`(由它统一设置 pending 态),删除手写的 dangerSql/pendingDangerSql/showDangerDialog 三行。**
|
||||
|
||||
### 🟢 低优先级(一并修)
|
||||
- **L1** EditorToolbar.vue:89 残留 `[TIER-DBG]` console.log(全分支唯一新增调试日志,违反指南阶段6)→ 删除。
|
||||
- **L2** querySurfaces.ts:13/:15 `focusSearch()`/`refreshData()` 接口声明缺 target 参数(实现已是 `(target: Element | null)`,契约漂移)→ 补参数声明。
|
||||
- **L3** EditorGroup.vue:126 模板内联单行 if 花括号(指南 §2.6 边缘项)→ 加花括号。
|
||||
- **L4** 双指针并发拖拽会话覆盖(EditorGroupTabBar)→ pointerdown 增加"已有活跃会话则忽略"守卫,一行。
|
||||
|
||||
### 已确认无需修复(审查中排除的疑点)
|
||||
- 收敛机制无无限循环(迟滞单调性可证);watch getter 依赖追踪正确;快捷键路由三处签名全一致;持久化 payload 类型与两 adapter 完全一致;legacy tabs-only 恢复、pinned 过滤后组修复、editorOnly 守卫全部 9 处 emit 点核查通过;跨栏拖拽 click 抑制依赖 DOM click 公共祖先语义,设计正确。
|
||||
- reorderTab 无生产调用方(仅 app-tests 使用):保留不改,PR 描述中注明。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. **P0-1**:App.vue:503 改 `tryExplain(undefined, { tabId })`;在 EditorGroup.mount.spec.ts 增加接真实 editorToolbarActions 注入(非 vi.fn 空壳)断言 explain 链路调用形状的用例。
|
||||
2. **P1-1**:queryStore showDirtyTabCloseConfirm 改 `activateTab(tab.id)`。
|
||||
3. **P1-2**:closeTab 空组剪除后调 `repairGroupSizes()`。
|
||||
4. **P1-3**:cleanupTabDrag 加源实例守卫;handleTabPointerDown 加活跃会话守卫(兼修 L4)。
|
||||
5. **P1-4**:AI danger 分支改走 `tryExecute(sql, { tabId })` 统一 pending 态管理。
|
||||
6. **L1-L3**:删调试日志、补接口参数、模板花括号。
|
||||
7. **验证**:每步跑定向 vitest;全部完成后跑 `pnpm typecheck` + `pnpm lint` + `pnpm build` + `git diff --check` + 全量 `pnpm test -- --maxWorkers=4`(维持本分支 1224 文件/11980 用例全绿的基线)。
|
||||
8. **输出**:修复完成后给出对照本清单的逐项核销报告;PR 描述建议中注明遗留项(ST1 双 ContentArea 架构灰区、GUI 验收未执行,均已在评审文档记录)。
|
||||
|
||||
## 不做的事
|
||||
- 不做 ST1 ContentArea 物理拆分(1500+ 行搬移,属独立工作项,评审文档已记录为遗留)。
|
||||
- 不动 reorderTab 死代码(有测试引用,PR 注明即可)。
|
||||
- 不新增功能;纯修复本 review 清单。
|
||||
+209
-182
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick, defineAsyncComponent } from "vue";
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick, defineAsyncComponent, provide } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { ChevronsRight, FileText } from "@lucide/vue";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import AppToolbar from "@/components/layout/AppToolbar.vue";
|
||||
import AppTabBar from "@/components/layout/AppTabBar.vue";
|
||||
import AppSidebar from "@/components/layout/AppSidebar.vue";
|
||||
import EditorToolbar from "@/components/layout/EditorToolbar.vue";
|
||||
import ContentArea from "@/components/layout/ContentArea.vue";
|
||||
import SqlEditorWorkspace from "@/components/layout/SqlEditorWorkspace.vue";
|
||||
import { EDITOR_TOOLBAR_ACTIONS } from "@/components/layout/editorToolbarActions";
|
||||
import AppDialogs from "@/components/layout/AppDialogs.vue";
|
||||
import DetachedTabHeader from "@/components/layout/DetachedTabHeader.vue";
|
||||
import WelcomeScreen from "@/components/layout/WelcomeScreen.vue";
|
||||
@@ -32,6 +32,7 @@ import MultiDbExecuteDialog from "@/components/editor/MultiDbExecuteDialog.vue";
|
||||
import { useDialogSources } from "@/composables/useDialogSources";
|
||||
import { useNavigationTargets } from "@/composables/useNavigationTargets";
|
||||
import { useDataGridActions } from "@/composables/useDataGridActions";
|
||||
import type { DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
|
||||
import { useTauriEvents } from "@/composables/useTauriEvents";
|
||||
import { useCloseActionPrompt, type AppCloseAction, type AppCloseRequestOptions } from "@/composables/useCloseActionPrompt";
|
||||
import { disposeAllSqlServerActivityTraces } from "@/lib/sqlserver/sqlServerActivityTraceRuntime";
|
||||
@@ -57,7 +58,7 @@ import { buildEditableObjectSource, buildExecutableObjectSourceStatements, execu
|
||||
import { loadEditableObjectSourceForEditor } from "@/lib/table/objectSourceLoad";
|
||||
import { schemaAfterConnectionSwitch } from "@/lib/schema/connectionSchemaInitialization";
|
||||
import { resolveHistorySqlRestoreTarget } from "@/lib/history/historyRestoreTarget";
|
||||
import { resolveExecutableSql, resolveExecutableSqlWithBackend, type SqlExecutionSnapshot } from "@/lib/sql/sqlExecutionTarget";
|
||||
import { resolveExecutableSql, resolveExecutableSqlWithBackend, type SqlExecutionOverride, type SqlExecutionSnapshot } from "@/lib/sql/sqlExecutionTarget";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import { isMacOS, isWindows } from "@/lib/backend/platform";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
@@ -96,7 +97,6 @@ import {
|
||||
switchToTabIndexFromShortcut,
|
||||
tabSwitcherDirectionFromShortcut,
|
||||
} from "@/lib/editor/keyboardShortcuts";
|
||||
import { isPreviewTab } from "@/lib/tabs/tabPresentation";
|
||||
import { createTabNavigationHistory, moveInTabNavigationHistory, recordTabVisit } from "@/lib/tabs/tabNavigationHistory";
|
||||
import { initialTabSwitcherSelection, moveTabSwitcherSelection, tabSwitcherOrder } from "@/lib/tabs/tabSwitcher";
|
||||
import { createTabSwitcherKeyboardController } from "@/lib/tabs/tabSwitcherKeyboard";
|
||||
@@ -286,7 +286,7 @@ const { sidebarWidth, aiPanelWidth, historyWidth, sqlLibraryWidth, sqlFilePanelW
|
||||
const aiAssistantRef = ref<AiAssistantHandle | null>(null);
|
||||
const appSidebarRef = ref<InstanceType<typeof AppSidebar> | null>(null);
|
||||
const appTabBarRef = ref<InstanceType<typeof AppTabBar> | null>(null);
|
||||
const contentAreaRef = ref<InstanceType<typeof ContentArea> | null>(null);
|
||||
const contentAreaRef = ref<InstanceType<typeof SqlEditorWorkspace> | null>(null);
|
||||
|
||||
const selectedSql = ref("");
|
||||
const cursorPos = ref(0);
|
||||
@@ -307,6 +307,7 @@ const queryEditorObjectSourceTarget = ref<{
|
||||
relationName?: string;
|
||||
} | null>(null);
|
||||
const showSaveSqlDialog = ref(false);
|
||||
const saveSqlDialogTabId = ref<string | null>(null);
|
||||
const sqlLibrarySaveFeedbackId = ref(0);
|
||||
const saveSqlConfirmButtonRef = ref<HTMLElement | { $el?: HTMLElement } | null>(null);
|
||||
const sqlLibraryFlyAnimation = ref<{
|
||||
@@ -352,19 +353,23 @@ async function emitDetachedEvent(event: string, payload: unknown) {
|
||||
|
||||
async function isPointOverMainTabBar(position: { x: number; y: number }): Promise<boolean> {
|
||||
if (isDetachedWindowContext) return false;
|
||||
const tabBar = document.querySelector<HTMLElement>("[data-main-tab-bar]");
|
||||
if (!tabBar) return false;
|
||||
// In the split workspace every pane's strip (and the special-surfaces bar)
|
||||
// carries the anchor; a point over any of them is a return-to-main drop.
|
||||
const tabBars = Array.from(document.querySelectorAll<HTMLElement>("[data-main-tab-bar]"));
|
||||
if (tabBars.length === 0) return false;
|
||||
try {
|
||||
const { getCurrentWindow } = await import("@tauri-apps/api/window");
|
||||
const currentWindow = getCurrentWindow();
|
||||
const [innerPosition, scaleFactor] = await Promise.all([currentWindow.innerPosition(), currentWindow.scaleFactor()]);
|
||||
const rect = tabBar.getBoundingClientRect();
|
||||
const safeScale = Number.isFinite(scaleFactor) && scaleFactor > 0 ? scaleFactor : 1;
|
||||
const left = innerPosition.x + rect.left * safeScale;
|
||||
const top = innerPosition.y + rect.top * safeScale;
|
||||
const right = innerPosition.x + rect.right * safeScale;
|
||||
const bottom = innerPosition.y + rect.bottom * safeScale;
|
||||
return position.x >= left && position.x <= right && position.y >= top && position.y <= bottom;
|
||||
return tabBars.some((tabBar) => {
|
||||
const rect = tabBar.getBoundingClientRect();
|
||||
const left = innerPosition.x + rect.left * safeScale;
|
||||
const top = innerPosition.y + rect.top * safeScale;
|
||||
const right = innerPosition.x + rect.right * safeScale;
|
||||
const bottom = innerPosition.y + rect.bottom * safeScale;
|
||||
return position.x >= left && position.x <= right && position.y >= top && position.y <= bottom;
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -641,7 +646,6 @@ const activeConnection = computed(() => {
|
||||
// Oracle connection uses the agent runtime but reports db_type "oracle"), so
|
||||
// the toolbar's Commit/Rollback visibility can apply the Oracle dirty-state rule
|
||||
// without inferring Oracle from the raw transport type.
|
||||
const isOracleManualTransaction = computed(() => effectiveDatabaseTypeForConnection(activeConnection.value) === "oracle" && (activeTab.value?.autoCommit ?? true) === false);
|
||||
|
||||
function updateAgentDriverUpdateCount(count: number) {
|
||||
if (!settingsStore.editorSettings.updateNotificationsEnabled) {
|
||||
@@ -704,12 +708,12 @@ async function resolveActiveExecutableSql(snapshot?: SqlExecutionSnapshot) {
|
||||
const blockDangerousRedisCommands = ref(true);
|
||||
const databaseRequiredSignal = ref(0);
|
||||
const databaseRequiredTabId = ref<string | null>(null);
|
||||
const pendingToolbarExecutionSnapshot = ref<SqlExecutionSnapshot>();
|
||||
const pendingToolbarExecutionSnapshot = ref<SqlExecutionSnapshot & { tabId?: string }>();
|
||||
const sqlExecutionDangerStore = useSqlExecutionDangerStore();
|
||||
const productionSafetyStore = useProductionSafetyStore();
|
||||
|
||||
function promptActiveDatabaseSelection() {
|
||||
const tab = activeTab.value;
|
||||
function promptActiveDatabaseSelection(tabId?: string) {
|
||||
const tab = tabId ? queryStore.tabs.find((candidate) => candidate.id === tabId) : activeTab.value;
|
||||
if (!tab) return;
|
||||
databaseRequiredTabId.value = tab.id;
|
||||
databaseRequiredSignal.value += 1;
|
||||
@@ -718,13 +722,13 @@ function promptActiveDatabaseSelection() {
|
||||
|
||||
const {
|
||||
dangerSql,
|
||||
pendingDangerSql,
|
||||
showDangerDialog,
|
||||
suppressDangerConfirm,
|
||||
tryExecute,
|
||||
tryExecuteInNewResultTab,
|
||||
doExecute,
|
||||
cancelActiveExecution,
|
||||
requestDangerConfirmation,
|
||||
tryExplain,
|
||||
onDangerConfirm,
|
||||
showSqlParameterDialog,
|
||||
@@ -750,7 +754,8 @@ const {
|
||||
});
|
||||
|
||||
function captureActiveEditorExecutionSnapshot() {
|
||||
pendingToolbarExecutionSnapshot.value = contentAreaRef.value?.captureQueryEditorExecutionSnapshot?.();
|
||||
const snapshot = contentAreaRef.value?.captureQueryEditorExecutionSnapshot?.();
|
||||
pendingToolbarExecutionSnapshot.value = snapshot ? { ...snapshot, tabId: activeTab.value?.id } : undefined;
|
||||
}
|
||||
|
||||
function requestActiveEditorExecute(source?: "pointer" | "keyboard") {
|
||||
@@ -758,7 +763,7 @@ function requestActiveEditorExecute(source?: "pointer" | "keyboard") {
|
||||
pendingToolbarExecutionSnapshot.value = undefined;
|
||||
if (source === "pointer") {
|
||||
if (snapshot) {
|
||||
void tryExecute(snapshot);
|
||||
void tryExecute(snapshot, { tabId: snapshot.tabId ?? activeTab.value?.id });
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -771,6 +776,36 @@ function requestActiveEditorExecuteInNewResultTab() {
|
||||
void tryExecuteInNewResultTab();
|
||||
}
|
||||
|
||||
// Per-group editor toolbars call back into this App-owned orchestration. The
|
||||
// group focuses itself on pointerdown/focusin before any toolbar event, so the
|
||||
// acting tab is passed explicitly instead of relying on the focused group.
|
||||
provide(EDITOR_TOOLBAR_ACTIONS, {
|
||||
explainMode,
|
||||
blockDangerousRedisCommands,
|
||||
databaseRequiredSignalFor: (tabId: string) => (databaseRequiredTabId.value === tabId ? databaseRequiredSignal.value : 0),
|
||||
captureExecutionSnapshot: captureActiveEditorExecutionSnapshot,
|
||||
toolbarExecute: requestActiveEditorExecute,
|
||||
cancelExecution: (tabId: string) => cancelActiveExecution(tabId),
|
||||
explain: (tabId: string) => tryExplain(undefined, { tabId }),
|
||||
formatSql: formatActiveSql,
|
||||
compressSql: compressActiveSql,
|
||||
toggleSqlKeywordCase,
|
||||
saveSql: (tabId: string) => void openSaveSqlDialog(tabId),
|
||||
openSqlFile,
|
||||
importResultArchive,
|
||||
pasteSqlInCondition: pasteClipboardAsSqlInCondition,
|
||||
multiExecute: requestMultiDbExecute,
|
||||
previewChanges: requestActiveEditorPreviewChanges,
|
||||
changeConnection: changeActiveConnection,
|
||||
changeCatalog: changeActiveCatalog,
|
||||
changeDatabase: changeActiveDatabase,
|
||||
changeSchema: changeActiveSchema,
|
||||
setDefaultDatabase: setActiveDatabaseAsDefault,
|
||||
clearDefaultDatabase: clearActiveDefaultDatabase,
|
||||
});
|
||||
|
||||
// Upstream "preview changes" entry: dormant until the group toolbar wires the
|
||||
// preview-changes button into the workspace routing (see merge notes).
|
||||
function requestActiveEditorPreviewChanges() {
|
||||
void contentAreaRef.value?.requestQueryEditorPreviewChanges?.();
|
||||
}
|
||||
@@ -866,6 +901,7 @@ const tabWorkspaceLayoutClass = computed(() => {
|
||||
return isVerticalTabPlacement.value ? "flex-row" : "flex-col";
|
||||
});
|
||||
|
||||
// Every pane's vertical strip writes back to this shared width/collapse state.
|
||||
function startTabBarResize(event: MouseEvent) {
|
||||
if (settingsStore.editorSettings.tabPlacement === "right") {
|
||||
startRightTabBarResize(event);
|
||||
@@ -877,6 +913,7 @@ function startTabBarResize(event: MouseEvent) {
|
||||
function toggleTabBarCollapsed() {
|
||||
setTabBarCollapsed(!tabBarCollapsed.value);
|
||||
}
|
||||
|
||||
const updateNotificationsEnabled = computed(() => settingsStore.editorSettings.updateNotificationsEnabled);
|
||||
|
||||
function openSettings(initialTab = "appearance", initialSection?: string) {
|
||||
@@ -1338,8 +1375,12 @@ function analyzeHistoryWithAi(entry: HistoryEntry) {
|
||||
invokeWhenAiReady((handle) => handle.triggerAction("explain", buildHistoryAiAnalysisPrompt(entry)));
|
||||
}
|
||||
|
||||
function formatActiveSql() {
|
||||
const tab = activeTab.value;
|
||||
function resolveToolbarTab(tabId?: string) {
|
||||
return tabId ? queryStore.tabs.find((candidate) => candidate.id === tabId) : activeTab.value;
|
||||
}
|
||||
|
||||
function formatActiveSql(tabId?: string) {
|
||||
const tab = resolveToolbarTab(tabId);
|
||||
if (!tab || tab.mode !== "query" || !tab.sql.trim()) return;
|
||||
formatSqlRequest.value = {
|
||||
id: (formatSqlRequest.value?.id ?? 0) + 1,
|
||||
@@ -1347,8 +1388,8 @@ function formatActiveSql() {
|
||||
};
|
||||
}
|
||||
|
||||
function compressActiveSql() {
|
||||
const tab = activeTab.value;
|
||||
function compressActiveSql(tabId?: string) {
|
||||
const tab = resolveToolbarTab(tabId);
|
||||
if (!tab || tab.mode !== "query" || !tab.sql.trim()) return;
|
||||
compressSqlRequest.value = {
|
||||
id: (compressSqlRequest.value?.id ?? 0) + 1,
|
||||
@@ -1432,7 +1473,8 @@ function closePendingSavedTab() {
|
||||
if (!pendingSaveAndCloseTabId.value) return;
|
||||
const closeId = pendingSaveAndCloseTabId.value;
|
||||
pendingSaveAndCloseTabId.value = null;
|
||||
if (pendingPrevActiveTabId.value) queryStore.activeTabId = pendingPrevActiveTabId.value;
|
||||
saveSqlDialogTabId.value = null;
|
||||
if (pendingPrevActiveTabId.value) queryStore.activateTab(pendingPrevActiveTabId.value);
|
||||
pendingPrevActiveTabId.value = null;
|
||||
const shouldCloseTab = pendingSaveShouldCloseTab.value;
|
||||
pendingSaveShouldCloseTab.value = true;
|
||||
@@ -1442,6 +1484,7 @@ function closePendingSavedTab() {
|
||||
function cancelPendingSaveAndClose() {
|
||||
invalidateSaveSqlFolderSelection();
|
||||
showSaveSqlDialog.value = false;
|
||||
saveSqlDialogTabId.value = null;
|
||||
pendingSaveAndCloseTabId.value = null;
|
||||
pendingPrevActiveTabId.value = null;
|
||||
pendingSaveShouldCloseTab.value = true;
|
||||
@@ -1636,7 +1679,7 @@ async function formattedSqlForSave(tab: QueryTab): Promise<string> {
|
||||
async function saveTabForCloseAll(tabId: string): Promise<boolean> {
|
||||
const tab = queryStore.tabs.find((t) => t.id === tabId);
|
||||
if (!tab) return true;
|
||||
queryStore.activeTabId = tabId;
|
||||
queryStore.activateTab(tabId);
|
||||
|
||||
if (tab.mode === "structure") {
|
||||
await nextTick();
|
||||
@@ -1693,7 +1736,7 @@ async function handleSaveTab(tabId: string) {
|
||||
const tab = queryStore.tabs.find((t) => t.id === tabId);
|
||||
if (!tab) return;
|
||||
if (tab.mode === "structure") {
|
||||
queryStore.activeTabId = tabId;
|
||||
queryStore.activateTab(tabId);
|
||||
await nextTick();
|
||||
if (await contentAreaRef.value?.applyTableStructureChanges?.()) {
|
||||
completePendingTabSave(tabId);
|
||||
@@ -1741,7 +1784,8 @@ async function handleSaveTab(tabId: string) {
|
||||
}
|
||||
// No existing saved SQL — open save dialog, then close after save
|
||||
const prevActive = queryStore.activeTabId;
|
||||
queryStore.activeTabId = tabId;
|
||||
queryStore.activateTab(tabId);
|
||||
saveSqlDialogTabId.value = tabId;
|
||||
saveSqlName.value = defaultSavedSqlName(tab.title);
|
||||
resetSaveSqlFolderSelection(ROOT_SAVED_SQL_FOLDER);
|
||||
pendingSaveAndCloseTabId.value = tabId;
|
||||
@@ -1749,9 +1793,10 @@ async function handleSaveTab(tabId: string) {
|
||||
showSaveSqlDialog.value = true;
|
||||
}
|
||||
|
||||
async function openSaveSqlDialog() {
|
||||
const tab = activeTab.value;
|
||||
async function openSaveSqlDialog(tabId?: string) {
|
||||
const tab = tabId ? queryStore.tabs.find((candidate) => candidate.id === tabId) : activeTab.value;
|
||||
if (!tab || !canSaveSqlTab(tab)) return;
|
||||
saveSqlDialogTabId.value = tab.id;
|
||||
if (tab.objectSource) {
|
||||
await saveActiveObjectSource(tab);
|
||||
return;
|
||||
@@ -1843,7 +1888,7 @@ async function handleSaveSqlFolderSelect(value: string) {
|
||||
await selectSaveSqlFolder(value);
|
||||
return;
|
||||
}
|
||||
const tab = activeTab.value;
|
||||
const tab = saveSqlDialogTabId.value ? queryStore.tabs.find((candidate) => candidate.id === saveSqlDialogTabId.value) : activeTab.value;
|
||||
if (!tab) return;
|
||||
await selectSaveSqlFolder(
|
||||
value,
|
||||
@@ -1854,7 +1899,7 @@ async function handleSaveSqlFolderSelect(value: string) {
|
||||
|
||||
async function confirmSaveSqlToLibrary() {
|
||||
if (saveSqlFolderCreationPending.value) return;
|
||||
const tab = activeTab.value;
|
||||
const tab = saveSqlDialogTabId.value ? queryStore.tabs.find((candidate) => candidate.id === saveSqlDialogTabId.value) : activeTab.value;
|
||||
const name = saveSqlName.value.trim();
|
||||
if (!tab || !tab.sql.trim() || !name) return;
|
||||
const flyOrigin = elementCenter(saveSqlConfirmButtonElement());
|
||||
@@ -1873,6 +1918,7 @@ async function confirmSaveSqlToLibrary() {
|
||||
queryStore.linkSavedSql(tab.id, saved.id, saved.name);
|
||||
queryStore.markTabClean(tab);
|
||||
showSaveSqlDialog.value = false;
|
||||
saveSqlDialogTabId.value = null;
|
||||
closePendingSavedTab();
|
||||
void notifyNewSqlLibrarySaved(flyOrigin);
|
||||
} catch (e: any) {
|
||||
@@ -1903,7 +1949,7 @@ async function saveExternalSqlTabAs(tab: QueryTab): Promise<boolean> {
|
||||
}
|
||||
|
||||
async function saveActiveSqlAsLocalFile() {
|
||||
const tab = activeTab.value;
|
||||
const tab = saveSqlDialogTabId.value ? queryStore.tabs.find((candidate) => candidate.id === saveSqlDialogTabId.value) : activeTab.value;
|
||||
if (tab) await saveExternalSqlTabAs(tab);
|
||||
}
|
||||
|
||||
@@ -2439,8 +2485,8 @@ function onQueryEditorObjectSourceSaved() {
|
||||
contentAreaRef.value?.refreshQueryEditorCompletionCache();
|
||||
}
|
||||
|
||||
async function changeActiveConnection(connectionId: string) {
|
||||
const tab = activeTab.value;
|
||||
async function changeActiveConnection(connectionId: string, tabId?: string) {
|
||||
const tab = resolveToolbarTab(tabId);
|
||||
if (!tab) return;
|
||||
const connection = connectionStore.getConfig(connectionId);
|
||||
if (!connection) return;
|
||||
@@ -2459,7 +2505,8 @@ async function changeActiveConnection(connectionId: string) {
|
||||
// A configured default wins. Otherwise Oracle returns the session's current schema first.
|
||||
const orderedSchemas = connection.default_schema ? [] : await api.listSchemas(connectionId, database);
|
||||
const schema = schemaAfterConnectionSwitch(connection.db_type, orderedSchemas, connection.default_schema);
|
||||
if (schema && activeTab.value?.id === tab.id && activeTab.value.connectionId === connectionId) {
|
||||
const latestTab = queryStore.tabs.find((candidate) => candidate.id === tab.id);
|
||||
if (schema && latestTab && latestTab.connectionId === connectionId) {
|
||||
queryStore.updateSchema(tab.id, schema);
|
||||
if (tab.externalSqlPath) rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId, database, catalog: undefined, schema });
|
||||
}
|
||||
@@ -2477,8 +2524,8 @@ async function changeActiveConnection(connectionId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function changeActiveDatabase(database: string) {
|
||||
const tab = activeTab.value;
|
||||
function changeActiveDatabase(database: string, tabId?: string) {
|
||||
const tab = resolveToolbarTab(tabId);
|
||||
if (tab) {
|
||||
queryStore.updateDatabase(tab.id, database);
|
||||
if (tab.externalSqlPath) rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId: tab.connectionId, database, catalog: tab.catalog, schema: tab.schema });
|
||||
@@ -2488,28 +2535,28 @@ function changeActiveDatabase(database: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function changeActiveCatalog(catalog: string | undefined, database: string) {
|
||||
const tab = activeTab.value;
|
||||
function changeActiveCatalog(catalog: string | undefined, database: string, tabId?: string) {
|
||||
const tab = resolveToolbarTab(tabId);
|
||||
if (tab) {
|
||||
queryStore.updateCatalog(tab.id, catalog, database);
|
||||
if (tab.externalSqlPath) rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId: tab.connectionId, database, catalog, schema: tab.schema });
|
||||
}
|
||||
}
|
||||
|
||||
async function setActiveDatabaseAsDefault() {
|
||||
const tab = activeTab.value;
|
||||
async function setActiveDatabaseAsDefault(tabId?: string) {
|
||||
const tab = resolveToolbarTab(tabId);
|
||||
if (!tab || !tab.connectionId || !tab.database || tab.catalog) return;
|
||||
await connectionStore.setDefaultDatabase(tab.connectionId, tab.database);
|
||||
}
|
||||
|
||||
async function clearActiveDefaultDatabase() {
|
||||
const tab = activeTab.value;
|
||||
async function clearActiveDefaultDatabase(tabId?: string) {
|
||||
const tab = resolveToolbarTab(tabId);
|
||||
if (!tab || !tab.connectionId) return;
|
||||
await connectionStore.clearDefaultDatabase(tab.connectionId);
|
||||
}
|
||||
|
||||
function changeActiveSchema(schema: string | undefined) {
|
||||
const tab = activeTab.value;
|
||||
function changeActiveSchema(schema: string | undefined, tabId?: string) {
|
||||
const tab = resolveToolbarTab(tabId);
|
||||
if (!tab) return;
|
||||
queryStore.updateSchema(tab.id, schema);
|
||||
if (tab.externalSqlPath) rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId: tab.connectionId, database: tab.database, catalog: tab.catalog, schema });
|
||||
@@ -2554,7 +2601,7 @@ function routeAiRedisCommand(command: string, execute: boolean): boolean {
|
||||
console.warn("[DBX] Redis AI command could not reach the active Redis console");
|
||||
return true;
|
||||
}
|
||||
void routed.then((handled) => {
|
||||
void routed.then((handled: any) => {
|
||||
if (!handled) console.warn("[DBX] Redis AI command could not reach the active Redis console");
|
||||
});
|
||||
return true;
|
||||
@@ -2568,9 +2615,9 @@ function onAiAppendSql(sql: string) {
|
||||
if (appendedSql !== currentSql) queryStore.updateSql(tabId, appendedSql);
|
||||
}
|
||||
|
||||
function runAiGeneratedSql(sql: string) {
|
||||
function runAiGeneratedSql(sql: string, tabId = activeTab.value?.id) {
|
||||
selectedSql.value = "";
|
||||
nextTick(() => tryExecute(sql));
|
||||
nextTick(() => tryExecute(sql, { tabId }));
|
||||
}
|
||||
|
||||
function onAiExecuteSql(sql: string) {
|
||||
@@ -2579,13 +2626,13 @@ function onAiExecuteSql(sql: string) {
|
||||
const currentSql = queryStore.tabs.find((tab) => tab.id === tabId)?.sql ?? "";
|
||||
const appendedSql = buildDeduplicatedAppendedEditorSql(currentSql, sql);
|
||||
if (appendedSql !== currentSql) queryStore.updateSql(tabId, appendedSql);
|
||||
runAiGeneratedSql(sql);
|
||||
runAiGeneratedSql(sql, tabId);
|
||||
}
|
||||
|
||||
function onAiTempRunSql(sql: string) {
|
||||
if (routeAiRedisCommand(sql, true)) return;
|
||||
ensureQueryTab();
|
||||
runAiGeneratedSql(sql);
|
||||
const tabId = ensureQueryTab();
|
||||
runAiGeneratedSql(sql, tabId);
|
||||
}
|
||||
|
||||
function onAiRequestAutoExecuteSql(sql: string) {
|
||||
@@ -2608,12 +2655,10 @@ function onAiRequestAutoExecuteSql(sql: string) {
|
||||
|
||||
nextTick(() => {
|
||||
if (decision.action === "auto_execute") {
|
||||
void doExecute(sql);
|
||||
void doExecute(sql, undefined, { tabId });
|
||||
return;
|
||||
}
|
||||
dangerSql.value = sql;
|
||||
pendingDangerSql.value = sql;
|
||||
showDangerDialog.value = true;
|
||||
requestDangerConfirmation(sql, tabId);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2622,7 +2667,7 @@ function onAiOpenExplainPlan(sql: string) {
|
||||
queryStore.updateSql(tabId, buildAppendedEditorSql(activeTab.value?.sql || "", sql));
|
||||
selectedSql.value = "";
|
||||
nextTick(() => {
|
||||
void tryExplain(sql);
|
||||
void tryExplain(sql, { tabId });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2807,7 +2852,7 @@ function dispatchBeforeTabSwitch(tabId: string) {
|
||||
function activateQueryTab(tabId: string): boolean {
|
||||
if (!queryStore.tabs.some((tab) => tab.id === tabId)) return false;
|
||||
dispatchBeforeTabSwitch(tabId);
|
||||
queryStore.activeTabId = tabId;
|
||||
if (!queryStore.activateTab(tabId)) return false;
|
||||
activateQuerySurface();
|
||||
return true;
|
||||
}
|
||||
@@ -2940,7 +2985,9 @@ async function handleKeydown(e: KeyboardEvent) {
|
||||
return;
|
||||
}
|
||||
if (isFocusSearchShortcut(e, shortcuts)) {
|
||||
const focused = contentAreaRef.value?.focusSearch() || appSidebarRef.value?.focusSearch();
|
||||
// Route by the event target so focus inside the shared result grid (or a
|
||||
// Teleported cell-detail dialog) lands on the result search, not the editor.
|
||||
const focused = contentAreaRef.value?.focusSearch(e.target instanceof Element ? e.target : null) || appSidebarRef.value?.focusSearch();
|
||||
if (focused) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -3390,13 +3437,8 @@ onUnmounted(() => {
|
||||
:agent-driver-update-count="toolbarAgentDriverUpdateCount"
|
||||
:detached-drop-target="detachedDropTargetTabId !== null"
|
||||
:can-detach-tabs="isDesktop"
|
||||
:tab-bar-width="tabBarWidth"
|
||||
:tab-bar-collapsed="tabBarCollapsed"
|
||||
@toggle-zen-mode="toggleZenMode"
|
||||
@activate-driver-store="openDriverStorePage"
|
||||
@activate-settings-page="activateSettingsPage"
|
||||
@locate-tab="locateTabInSidebar"
|
||||
@activate-tab="activateQuerySurface"
|
||||
@close-driver-store="closeDriverStorePage"
|
||||
@close-settings-page="closeSettingsPage"
|
||||
@save-tab="handleSaveTab"
|
||||
@@ -3405,8 +3447,6 @@ onUnmounted(() => {
|
||||
@discard-all-tab-close="handleDiscardAllPendingTabClose"
|
||||
@cancel-tab-close="cancelPendingAppClose"
|
||||
@detach-tab="detachTab"
|
||||
@start-resize="startTabBarResize"
|
||||
@toggle-collapse="toggleTabBarCollapsed"
|
||||
/>
|
||||
<DetachedTabHeader
|
||||
v-else-if="activeTab"
|
||||
@@ -3449,129 +3489,115 @@ onUnmounted(() => {
|
||||
@update:open="(open: boolean) => (open ? activateSettingsPage() : closeSettingsPage())"
|
||||
@check-updates="checkUpdates()"
|
||||
/>
|
||||
<div v-if="activeTab" v-show="!driverStoreActive && !settingsStore.settingsPageActive" class="flex flex-col flex-1 min-h-0">
|
||||
<EditorToolbar
|
||||
v-if="activeTab.mode === 'query' && !isPreviewTab(activeTab)"
|
||||
:active-tab="activeTab"
|
||||
<div v-if="queryStore.tabs.length > 0" v-show="!driverStoreActive && !settingsStore.settingsPageActive" class="flex flex-col flex-1 min-h-0">
|
||||
<SqlEditorWorkspace
|
||||
ref="contentAreaRef"
|
||||
@locate-tab="locateTabInSidebar"
|
||||
@toggle-zen-mode="toggleZenMode"
|
||||
@start-resize="startTabBarResize"
|
||||
@toggle-collapse="toggleTabBarCollapsed"
|
||||
:active-tab="activeTab!"
|
||||
:active-connection="activeConnection"
|
||||
:tab-bar-width="tabBarWidth"
|
||||
:tab-bar-collapsed="tabBarCollapsed"
|
||||
:can-detach-tabs="isDesktop"
|
||||
:detached-drop-target="detachedDropTargetTabId !== null"
|
||||
@detach-tab="detachTab"
|
||||
:executable-sql="executableSql"
|
||||
:can-preview-changes="previewChangesAvailable"
|
||||
:explain-mode="explainMode"
|
||||
:active-output-view="activeOutputView"
|
||||
:format-sql-request="formatSqlRequest"
|
||||
:compress-sql-request="compressSqlRequest"
|
||||
:selected-sql="selectedSql"
|
||||
:cursor-pos="cursorPos"
|
||||
:block-dangerous-redis-commands="blockDangerousRedisCommands"
|
||||
:sql-keyword-case="settingsStore.editorSettings.sqlFormatter.keywordCase"
|
||||
:database-required-signal="databaseRequiredTabId === activeTab.id ? databaseRequiredSignal : 0"
|
||||
: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="isOracleManualTransaction"
|
||||
@update:explain-mode="(m: 'explain' | 'autotrace') => (explainMode = m)"
|
||||
@update:block-dangerous-redis-commands="(v: boolean) => (blockDangerousRedisCommands = v)"
|
||||
@update:auto-commit="
|
||||
(v: boolean) => {
|
||||
if (activeTab) queryStore.setAutoCommit(activeTab.id, v);
|
||||
:zen-mode="isZenMode"
|
||||
@update:active-output-view="
|
||||
(tabId: string, view: 'result' | 'summary' | 'explain' | 'chart' | 'messages') => {
|
||||
if (tabId === queryStore.activeTabId) activeOutputView = view;
|
||||
}
|
||||
"
|
||||
@commit="activeTab && queryStore.commitTransaction(activeTab.id)"
|
||||
@rollback="activeTab && queryStore.rollbackTransaction(activeTab.id)"
|
||||
@dismiss-txn-rolled-back="activeTab && (activeTab.txnAutoRolledBack = false)"
|
||||
@execute-pointer-down="captureActiveEditorExecutionSnapshot()"
|
||||
@execute="requestActiveEditorExecute($event)"
|
||||
@preview-changes="requestActiveEditorPreviewChanges()"
|
||||
@multi-execute="requestMultiDbExecute()"
|
||||
@cancel="cancelActiveExecution()"
|
||||
@explain="tryExplain()"
|
||||
@format-sql="formatActiveSql"
|
||||
@compress-sql="compressActiveSql"
|
||||
@toggle-sql-keyword-case="toggleSqlKeywordCase"
|
||||
@save-sql="void openSaveSqlDialog()"
|
||||
@open-sql="openSqlFile"
|
||||
@import-result-archive="importResultArchive"
|
||||
@paste-sql-in-condition="pasteClipboardAsSqlInCondition"
|
||||
@change-connection="changeActiveConnection"
|
||||
@change-database="changeActiveDatabase"
|
||||
@change-catalog="changeActiveCatalog"
|
||||
@change-schema="changeActiveSchema"
|
||||
@set-default-database="setActiveDatabaseAsDefault"
|
||||
@clear-default-database="clearActiveDefaultDatabase"
|
||||
@preview-changes-available="
|
||||
(tabId: string, value: boolean) => {
|
||||
if (tabId === queryStore.activeTabId) previewChangesAvailable = value;
|
||||
}
|
||||
"
|
||||
@fix-with-ai="(_tabId: string, message: string) => fixWithAi(message)"
|
||||
@send-selection-to-ai="
|
||||
(tabId: string, sql: string) => {
|
||||
if (tabId === queryStore.activeTabId) sendSelectionToAi(sql);
|
||||
}
|
||||
"
|
||||
@execute="(tabId: string, override?: SqlExecutionOverride) => tryExecute(override, { tabId })"
|
||||
@execute-in-new-result-tab="(tabId: string, override?: SqlExecutionOverride) => tryExecuteInNewResultTab(override, { tabId })"
|
||||
@cancel="(tabId: string) => cancelActiveExecution(tabId)"
|
||||
@explain="(tabId: string) => tryExplain(undefined, { tabId })"
|
||||
@editor-update="(tabId: string, v: string) => queryStore.updateSql(tabId, v)"
|
||||
@editor-selection-change="
|
||||
(tabId: string, v: string) => {
|
||||
if (tabId === queryStore.activeTabId) selectedSql = v;
|
||||
}
|
||||
"
|
||||
@editor-cursor-change="
|
||||
(tabId: string, p: number) => {
|
||||
if (tabId === queryStore.activeTabId) cursorPos = p;
|
||||
}
|
||||
"
|
||||
@editor-viewport-change="(tabId: string, viewport: { scrollTop: number; scrollLeft: number }) => queryStore.updateEditorViewport(tabId, viewport)"
|
||||
@editor-selection-state-change="(tabId: string, selection: { anchor: number; head: number }) => queryStore.updateEditorSelection(tabId, selection)"
|
||||
@format-error="toast(t('toolbar.formatSqlFailed'))"
|
||||
@save-sql="(tabId: string) => void openSaveSqlDialog(tabId)"
|
||||
@reload="(tabId: string, sql: any, searchText: any, whereInput: any, orderBy: any, limit: any, offset: any, intent: any) => onReloadData(tabId, sql, searchText, whereInput, orderBy, limit, offset, intent)"
|
||||
@paginate="(tabId: string, offset: number, limit: number, whereInput?: string, orderBy?: string) => onPaginate(tabId, offset, limit, whereInput, orderBy)"
|
||||
@sort="(tabId: string, column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => onSort(tabId, column, columnIndex, direction, whereInput, mode)"
|
||||
@execute-sql="(tabId: string, sql: string) => onExecuteSql(tabId, sql)"
|
||||
@click-table="(_tabId: string, target: SqlObjectNavigationTarget) => onClickTable(target)"
|
||||
@view-table-data="(_tabId: string, target: SqlObjectNavigationTarget) => onViewTableData(target)"
|
||||
@edit-table-structure="(_tabId: string, target: SqlObjectNavigationTarget) => onEditTableStructure(target)"
|
||||
@view-table-ddl="(_tabId: string, target: SqlObjectNavigationTarget) => onViewTableDdl(target)"
|
||||
@open-object-source="(_tabId: string, target: SqlObjectNavigationTarget, initialEditing: boolean) => onOpenObjectSource(target, initialEditing)"
|
||||
@open-object-table="
|
||||
(tabId: string, target: { tableName: string; schema?: string; tableType?: string; catalog?: string }) => {
|
||||
const tab = queryStore.tabs.find((candidate) => candidate.id === tabId) ?? activeTab;
|
||||
if (!tab) return;
|
||||
openObjectBrowserTableTarget({
|
||||
connectionId: tab.connectionId,
|
||||
database: tab.database,
|
||||
schema: target.schema,
|
||||
catalog: target.catalog,
|
||||
tableName: target.tableName,
|
||||
tableType: target.tableType,
|
||||
});
|
||||
}
|
||||
"
|
||||
@object-schema-change="(tabId: string, schema: string | undefined) => queryStore.updateSchema(tabId, schema)"
|
||||
@object-browser-viewport-change="(tabId: string, viewport: any) => queryStore.updateObjectBrowserViewport(tabId, viewport)"
|
||||
@structure-editor-saved="
|
||||
(tabId: string, commentChanged: boolean) => {
|
||||
const tab = queryStore.tabs.find((candidate) => candidate.id === tabId);
|
||||
if (!tab) return;
|
||||
onStructureEditorSaved(
|
||||
async () => {
|
||||
await onReloadData(tabId);
|
||||
},
|
||||
toast,
|
||||
{
|
||||
connectionId: tab.connectionId,
|
||||
database: tab.database,
|
||||
schema: tab.schema,
|
||||
catalog: tab.catalog,
|
||||
tableName: tab.structureTableName || '',
|
||||
},
|
||||
commentChanged,
|
||||
);
|
||||
}
|
||||
"
|
||||
@structure-editor-close="(tabId: string) => queryStore.closeTab(tabId)"
|
||||
@open-settings="openSettings"
|
||||
@open-connection-settings="openConnectionSettings"
|
||||
/>
|
||||
<KeepAlive :max="4">
|
||||
<ContentArea
|
||||
ref="contentAreaRef"
|
||||
:key="activeTab.id"
|
||||
:active-tab="activeTab"
|
||||
:active-connection="activeConnection"
|
||||
:executable-sql="executableSql"
|
||||
:active-output-view="activeOutputView"
|
||||
:format-sql-request="formatSqlRequest"
|
||||
:compress-sql-request="compressSqlRequest"
|
||||
:selected-sql="selectedSql"
|
||||
:cursor-pos="cursorPos"
|
||||
:block-dangerous-redis-commands="blockDangerousRedisCommands"
|
||||
:zen-mode="isZenMode"
|
||||
@update:active-output-view="activeOutputView = $event"
|
||||
@fix-with-ai="fixWithAi"
|
||||
@send-selection-to-ai="sendSelectionToAi"
|
||||
@execute="tryExecute($event)"
|
||||
@execute-in-new-result-tab="tryExecuteInNewResultTab($event)"
|
||||
@cancel="cancelActiveExecution()"
|
||||
@explain="tryExplain()"
|
||||
@editor-update="(tabId: string, v: string) => queryStore.updateSql(tabId, v)"
|
||||
@editor-selection-change="(v: string) => (selectedSql = v)"
|
||||
@editor-cursor-change="(p: number) => (cursorPos = p)"
|
||||
@preview-changes-available="(v: boolean) => (previewChangesAvailable = v)"
|
||||
@editor-viewport-change="(tabId: string, viewport: { scrollTop: number; scrollLeft: number }) => queryStore.updateEditorViewport(tabId, viewport)"
|
||||
@editor-selection-state-change="(tabId: string, selection: { anchor: number; head: number }) => queryStore.updateEditorSelection(tabId, selection)"
|
||||
@format-error="toast(t('toolbar.formatSqlFailed'))"
|
||||
@save-sql="void openSaveSqlDialog()"
|
||||
@reload="(sql, searchText, whereInput, orderBy, limit, offset, intent) => onReloadData(sql, searchText, whereInput, orderBy, limit, offset, intent)"
|
||||
@paginate="onPaginate"
|
||||
@sort="onSort"
|
||||
@execute-sql="onExecuteSql"
|
||||
@click-table="onClickTable"
|
||||
@view-table-data="onViewTableData"
|
||||
@edit-table-structure="onEditTableStructure"
|
||||
@view-table-ddl="onViewTableDdl"
|
||||
@open-object-source="onOpenObjectSource"
|
||||
@open-object-table="
|
||||
(target) =>
|
||||
activeTab &&
|
||||
openObjectBrowserTableTarget({
|
||||
connectionId: activeTab.connectionId,
|
||||
database: activeTab.database,
|
||||
schema: target.schema,
|
||||
catalog: target.catalog,
|
||||
tableName: target.tableName,
|
||||
tableType: target.tableType,
|
||||
})
|
||||
"
|
||||
@object-schema-change="(schema) => activeTab && queryStore.updateSchema(activeTab.id, schema)"
|
||||
@object-browser-viewport-change="(tabId, viewport) => queryStore.updateObjectBrowserViewport(tabId, viewport)"
|
||||
@structure-editor-saved="
|
||||
(commentChanged) =>
|
||||
activeTab &&
|
||||
onStructureEditorSaved(
|
||||
onReloadData,
|
||||
toast,
|
||||
{
|
||||
connectionId: activeTab.connectionId,
|
||||
database: activeTab.database,
|
||||
schema: activeTab.schema,
|
||||
catalog: activeTab.catalog,
|
||||
tableName: activeTab.structureTableName || '',
|
||||
},
|
||||
commentChanged,
|
||||
)
|
||||
"
|
||||
@structure-editor-close="activeTab && queryStore.closeTab(activeTab.id)"
|
||||
@open-settings="openSettings"
|
||||
@open-connection-settings="openConnectionSettings"
|
||||
@toggle-zen-mode="toggleZenMode"
|
||||
/>
|
||||
</KeepAlive>
|
||||
</div>
|
||||
<WelcomeScreen
|
||||
v-else-if="!driverStoreActive && !settingsStore.settingsPageActive"
|
||||
v-else-if="queryStore.tabs.length === 0 && !driverStoreActive && !settingsStore.settingsPageActive"
|
||||
:connection-stats="connectionStats"
|
||||
:recent-connections="recentConnections"
|
||||
:saved-sql-history-items="savedSqlHistoryItems"
|
||||
@@ -3768,6 +3794,7 @@ onUnmounted(() => {
|
||||
showSaveSqlDialog = open;
|
||||
if (!open) {
|
||||
invalidateSaveSqlFolderSelection();
|
||||
saveSqlDialogTabId = null;
|
||||
if (pendingSaveAndCloseTabId) cancelPendingSaveAndClose();
|
||||
}
|
||||
}
|
||||
|
||||
+12
-2
@@ -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
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
+5
-3
@@ -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", () => {
|
||||
|
||||
+5
-1
@@ -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;");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+2
-1
@@ -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();
|
||||
});
|
||||
});
|
||||
+562
@@ -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();
|
||||
});
|
||||
});
|
||||
+421
@@ -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();
|
||||
});
|
||||
});
|
||||
+101
@@ -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();
|
||||
});
|
||||
});
|
||||
+100
@@ -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();
|
||||
});
|
||||
});
|
||||
+461
@@ -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)");
|
||||
});
|
||||
});
|
||||
+40
@@ -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]")');
|
||||
});
|
||||
});
|
||||
+44
@@ -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>
|
||||
@@ -155,7 +155,7 @@ describe("useDataGridActions", () => {
|
||||
const tab = tableDataTab();
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
|
||||
await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
|
||||
|
||||
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -191,7 +191,9 @@ describe("useDataGridActions", () => {
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
const { whereInput, orderBy } = restoredDataTabReloadFilters(tab);
|
||||
await actions.onReloadData(undefined, undefined, whereInput, orderBy);
|
||||
// ContentArea's empty-placeholder refresh emits the owning tabId first
|
||||
// (tabId-first contract), so the filters land in the whereInput/orderBy slots.
|
||||
await actions.onReloadData(tab.id, undefined, undefined, whereInput, orderBy);
|
||||
|
||||
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ whereInput: "status = 'active'", orderBy: '"name" DESC' }));
|
||||
expect(tab.whereInput).toBe("status = 'active'");
|
||||
@@ -205,7 +207,7 @@ describe("useDataGridActions", () => {
|
||||
mocks.tabs.push(tab);
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onReloadData(tab.sql, "", undefined, undefined, undefined, undefined, "refresh");
|
||||
await actions.onReloadData(tab.id, tab.sql, "", undefined, undefined, undefined, undefined, "refresh");
|
||||
|
||||
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ whereInput: undefined, orderBy: undefined }));
|
||||
expect(tab.whereInput).toBe("");
|
||||
@@ -219,7 +221,7 @@ describe("useDataGridActions", () => {
|
||||
mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT * FROM public.users LIMIT 25 OFFSET 50");
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onReloadData(tab.sql, "", "", "", 25, 50, "refresh");
|
||||
await actions.onReloadData(tab.id, tab.sql, "", "", "", 25, 50, "refresh");
|
||||
|
||||
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ limit: 25, offset: 50 }));
|
||||
expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT * FROM public.users LIMIT 25 OFFSET 50", expect.objectContaining({ pagination: { limit: 25, offset: 50 } }));
|
||||
@@ -238,7 +240,7 @@ describe("useDataGridActions", () => {
|
||||
mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT * FROM public.users LIMIT 100 OFFSET 100");
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onPaginate(100, 100);
|
||||
await actions.onPaginate(tab.id, 100, 100);
|
||||
|
||||
expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT * FROM public.users LIMIT 100 OFFSET 100", expect.objectContaining({ appendResult: { maxRows: 10_000 } }));
|
||||
});
|
||||
@@ -264,7 +266,7 @@ describe("useDataGridActions", () => {
|
||||
mocks.tabs.push(tab);
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onReloadData(tab.sql, "", "", '"old_name" ASC', undefined, undefined, "refresh");
|
||||
await actions.onReloadData(tab.id, tab.sql, "", "", '"old_name" ASC', undefined, undefined, "refresh");
|
||||
|
||||
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy: undefined }));
|
||||
expect(tab.resultSortColumn).toBeUndefined();
|
||||
@@ -289,7 +291,7 @@ describe("useDataGridActions", () => {
|
||||
mocks.tabs.push(tab);
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onReloadData(tab.sql, "", "", '"old_name" ASC', undefined, undefined, "refresh");
|
||||
await actions.onReloadData(tab.id, tab.sql, "", "", '"old_name" ASC', undefined, undefined, "refresh");
|
||||
|
||||
expect(mocks.clearInvalidDataTabSort).toHaveReturnedWith(false);
|
||||
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy: undefined }));
|
||||
@@ -317,7 +319,7 @@ describe("useDataGridActions", () => {
|
||||
mocks.tabs.push(tab);
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onReloadData(tab.sql, "", "", "LOWER(new_name) ASC", undefined, undefined, "refresh");
|
||||
await actions.onReloadData(tab.id, tab.sql, "", "", "LOWER(new_name) ASC", undefined, undefined, "refresh");
|
||||
|
||||
expect(mocks.clearInvalidDataTabSort).toHaveReturnedWith(true);
|
||||
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy: "LOWER(new_name) ASC" }));
|
||||
@@ -341,7 +343,7 @@ describe("useDataGridActions", () => {
|
||||
} as QueryTab;
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
|
||||
await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
|
||||
|
||||
expect(mocks.buildTableSelectSql).not.toHaveBeenCalled();
|
||||
expect(mocks.executeTabSql).toHaveBeenCalledWith(
|
||||
@@ -366,7 +368,7 @@ describe("useDataGridActions", () => {
|
||||
mocks.tabs.push(tab);
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
|
||||
await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
|
||||
|
||||
// 合成的 ["Error"] 结果列不得进入 SQL 投影:真实列缺失时省略 columns(SELECT *)
|
||||
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ columns: undefined }));
|
||||
@@ -387,7 +389,7 @@ describe("useDataGridActions", () => {
|
||||
|
||||
// 第一轮:stale-tab 早退(tabs 里找不到匹配项时 refreshDataTabTableMeta 直接返回)
|
||||
mocks.tabs.length = 0;
|
||||
await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
|
||||
await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
|
||||
await vi.waitFor(() => expect(mocks.getColumns).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.setTableMeta).not.toHaveBeenCalled();
|
||||
expect(tab.tableMetaPending).toBe(true);
|
||||
@@ -395,7 +397,7 @@ describe("useDataGridActions", () => {
|
||||
// 第二轮:真实 columns 仍为空,新的消费者加入同一在途请求;共享缓存应
|
||||
// 去重后端调用,但本轮仍要在目标恢复后落地结果
|
||||
mocks.tabs.push(tab);
|
||||
await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
|
||||
await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
|
||||
await vi.waitFor(() => {
|
||||
expect(mocks.getColumns).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.setTableMeta).toHaveBeenCalledWith("tab-1", expect.objectContaining({ primaryKeys: ["id"] }));
|
||||
@@ -422,7 +424,7 @@ describe("useDataGridActions", () => {
|
||||
mocks.tabs.push(tab);
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
|
||||
await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
|
||||
|
||||
// Dameng 元数据必须排在数据查询之后(串行约束,同 useSidebarDataOpenRuntime);
|
||||
// 真实元数据落地后结束行标识等待
|
||||
@@ -445,7 +447,7 @@ describe("useDataGridActions", () => {
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
// 查询 reject 仍会重新抛出,但元数据刷新必须已启动,标签页可恢复
|
||||
await expect(actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh")).rejects.toThrow("query failed");
|
||||
await expect(actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh")).rejects.toThrow("query failed");
|
||||
await vi.waitFor(() => {
|
||||
expect(mocks.getColumns).toHaveBeenCalled();
|
||||
expect(tab.tableMetaPending).toBe(false);
|
||||
@@ -473,7 +475,7 @@ describe("useDataGridActions", () => {
|
||||
mocks.listIndexes.mockResolvedValueOnce([]);
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
|
||||
await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
|
||||
|
||||
expect(mocks.getColumns).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ columns: ["id", "age"] }));
|
||||
@@ -501,7 +503,7 @@ describe("useDataGridActions", () => {
|
||||
mocks.getColumns.mockResolvedValueOnce([{ name: "new_name", data_type: "text", is_nullable: true, column_default: null, is_primary_key: false, extra: null }]);
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
|
||||
await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
|
||||
|
||||
expect(mocks.ensureConnected).toHaveBeenCalled();
|
||||
expect(mocks.getColumns).toHaveBeenCalledTimes(1);
|
||||
@@ -514,7 +516,7 @@ describe("useDataGridActions", () => {
|
||||
mocks.tabs.push(tab);
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
|
||||
await actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
|
||||
|
||||
expect(mocks.getColumns).not.toHaveBeenCalled();
|
||||
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ columns: ["id"] }));
|
||||
@@ -535,7 +537,7 @@ describe("useDataGridActions", () => {
|
||||
}),
|
||||
);
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
const reload = actions.onReloadData(tab.sql, "", "", "", undefined, undefined, "refresh");
|
||||
const reload = actions.onReloadData(tab.id, tab.sql, "", "", "", undefined, undefined, "refresh");
|
||||
await vi.waitFor(() => expect(mocks.getColumns).toHaveBeenCalledTimes(1));
|
||||
|
||||
mocks.metadataGeneration = 1;
|
||||
@@ -574,7 +576,7 @@ describe("useDataGridActions", () => {
|
||||
} as QueryTab;
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onSort("email", 2, "asc");
|
||||
await actions.onSort(tab.id, "email", 2, "asc");
|
||||
|
||||
expect(mocks.executeTabSql).toHaveBeenCalledWith(
|
||||
"tab-1",
|
||||
@@ -644,7 +646,7 @@ describe("useDataGridActions", () => {
|
||||
});
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onPaginate(300, 100);
|
||||
await actions.onPaginate(tab.id, 300, 100);
|
||||
|
||||
expect(mocks.executeTabSql.mock.calls.map((call) => call[2].pagination)).toEqual([
|
||||
{ offset: 100, limit: 100, sessionId: "cursor-1", clientSessionId: "client-1" },
|
||||
@@ -677,7 +679,7 @@ describe("useDataGridActions", () => {
|
||||
mocks.getConfig.mockImplementation((id: string) => ({ id, db_type: "postgres" }));
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onPaginate(100, 100);
|
||||
await actions.onPaginate(tab.id, 100, 100);
|
||||
|
||||
expect(mocks.executeTabSql).toHaveBeenCalledWith(
|
||||
"tab-1",
|
||||
@@ -719,7 +721,7 @@ describe("useDataGridActions", () => {
|
||||
});
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onSort("sort_value", 0, "asc", "status = 'active'");
|
||||
await actions.onSort(tab.id, "sort_value", 0, "asc", "status = 'active'");
|
||||
|
||||
expect(tab.result?.rows.map((row) => row[0])).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
expect(tab.orderByInput).toBe('"sort_value" ASC');
|
||||
@@ -753,7 +755,7 @@ describe("useDataGridActions", () => {
|
||||
mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT sorted");
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onSort("sort_value", 0, direction, "status = 'active'", "database");
|
||||
await actions.onSort(tab.id, "sort_value", 0, direction, "status = 'active'", "database");
|
||||
|
||||
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy, whereInput: "status = 'active'", limit: 7, offset: 0 }));
|
||||
expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT sorted", expect.objectContaining({ pagination: { limit: 7, offset: 0 }, preserveTotalRowCountDuringExecution: true }));
|
||||
@@ -781,7 +783,7 @@ describe("useDataGridActions", () => {
|
||||
mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT unsorted");
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onSort("sort_value", 0, null, "status = 'active'", "database");
|
||||
await actions.onSort(tab.id, "sort_value", 0, null, "status = 'active'", "database");
|
||||
|
||||
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy: undefined, whereInput: "status = 'active'", limit: 10, offset: 0 }));
|
||||
expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT unsorted", expect.objectContaining({ pagination: { limit: 10, offset: 0 }, preserveTotalRowCountDuringExecution: true }));
|
||||
@@ -813,7 +815,7 @@ describe("useDataGridActions", () => {
|
||||
mocks.buildTableSelectSql.mockResolvedValueOnce("SELECT sorted page 2");
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onPaginate(10, 10, "status = 'active'");
|
||||
await actions.onPaginate(tab.id, 10, 10, "status = 'active'");
|
||||
|
||||
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(expect.objectContaining({ orderBy: '"sort_value" ASC', whereInput: "status = 'active'", limit: 10, offset: 10 }));
|
||||
expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT sorted page 2", expect.objectContaining({ pagination: { offset: 10, limit: 10 }, preserveTotalRowCountDuringExecution: true }));
|
||||
@@ -834,7 +836,7 @@ describe("useDataGridActions", () => {
|
||||
});
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onSort("sort_value", 0, "asc", "status = 'active'", "local");
|
||||
await actions.onSort(tab.id, "sort_value", 0, "asc", "status = 'active'", "local");
|
||||
|
||||
expect(mocks.sortTabResultLocally).toHaveBeenCalledWith("tab-1", "sort_value", 0, "asc");
|
||||
expect(mocks.buildTableSelectSql).not.toHaveBeenCalled();
|
||||
@@ -862,7 +864,7 @@ describe("useDataGridActions", () => {
|
||||
mocks.buildSortedQuerySql.mockResolvedValueOnce({ ok: true, sql: "SELECT sorted" });
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onSort("sort_value", 0, "desc", undefined, "database");
|
||||
await actions.onSort(tab.id, "sort_value", 0, "desc", undefined, "database");
|
||||
|
||||
expect(mocks.executeTabSql).toHaveBeenCalledWith("tab-1", "SELECT sorted", expect.objectContaining({ pagination: { limit: 10, offset: 0 }, preserveTotalRowCountDuringExecution: true }));
|
||||
});
|
||||
|
||||
+13
-13
@@ -219,7 +219,7 @@ describe("useSqlExecution", () => {
|
||||
|
||||
expect(execution.showSqlParameterDialog.value).toBe(false);
|
||||
expect(execution.sqlParameterNames.value).toEqual([]);
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(sql, {});
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1" });
|
||||
});
|
||||
|
||||
it("passes the selected statement's editor offset to the query store", async () => {
|
||||
@@ -245,7 +245,7 @@ describe("useSqlExecution", () => {
|
||||
|
||||
await execution.tryExecute({ fullSql, selectedSql, cursorPos: selectionFrom, selectionFrom, selectionTo: fullSql.length - 1 });
|
||||
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(selectedSql, { sourceOffset: selectionFrom });
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(selectedSql, { tabId: "tab-1", sourceOffset: selectionFrom });
|
||||
});
|
||||
|
||||
it("expands preceding @set values in a selected shell-style statement", async () => {
|
||||
@@ -273,7 +273,7 @@ describe("useSqlExecution", () => {
|
||||
await execution.tryExecute({ fullSql, selectedSql, cursorPos: selectionFrom, selectionFrom, selectionTo });
|
||||
|
||||
expect(execution.showSqlParameterDialog.value).toBe(false);
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith("SELECT * FROM patrol WHERE post_id = '224';", {});
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith("SELECT * FROM patrol WHERE post_id = '224';", { tabId: "tab-1" });
|
||||
});
|
||||
|
||||
it("opens the result table for a multi-statement batch by default", async () => {
|
||||
@@ -671,7 +671,7 @@ SELECT @value AS Message;`;
|
||||
|
||||
await execution.tryExecuteInNewResultTab();
|
||||
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { openInNewResultTab: true });
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1", openInNewResultTab: true });
|
||||
});
|
||||
|
||||
it("does not record or refresh when a new-result execution restores the prior run", async () => {
|
||||
@@ -723,7 +723,7 @@ SELECT @value AS Message;`;
|
||||
|
||||
await execution.onSqlParametersConfirm(resolvedSql);
|
||||
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(resolvedSql, { openInNewResultTab: true });
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(resolvedSql, { tabId: "tab-1", openInNewResultTab: true });
|
||||
});
|
||||
|
||||
it("executes the PostgreSQL ARRAY date-format query without opening the parameter dialog", async () => {
|
||||
@@ -783,7 +783,7 @@ SELECT @value AS Message;`;
|
||||
|
||||
expect(execution.showSqlParameterDialog.value).toBe(false);
|
||||
expect(execution.sqlParameterNames.value).toEqual([]);
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(sql, {});
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1" });
|
||||
});
|
||||
|
||||
it("executes Oracle database-link queries without opening the parameter dialog", async () => {
|
||||
@@ -808,7 +808,7 @@ SELECT @value AS Message;`;
|
||||
|
||||
expect(execution.showSqlParameterDialog.value).toBe(false);
|
||||
expect(execution.sqlParameterNames.value).toEqual([]);
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(sql, {});
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1" });
|
||||
});
|
||||
|
||||
it("sends native SET variables without client-side expansion", async () => {
|
||||
@@ -872,7 +872,7 @@ SELECT @value AS Message;`;
|
||||
|
||||
expect(execution.showSqlParameterDialog.value).toBe(false);
|
||||
expect(execution.sqlParameterNames.value).toEqual([]);
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(sql, {});
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1" });
|
||||
});
|
||||
|
||||
it("executes MySQL cursor procedures with compact labels without opening the parameter dialog", async () => {
|
||||
@@ -912,7 +912,7 @@ SELECT @value AS Message;`;
|
||||
|
||||
expect(execution.showSqlParameterDialog.value).toBe(false);
|
||||
expect(execution.sqlParameterNames.value).toEqual([]);
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(sql, {});
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -975,7 +975,7 @@ SELECT @value AS Message;`;
|
||||
|
||||
expect(execution.showSqlParameterDialog.value).toBe(false);
|
||||
expect(execution.sqlParameterNames.value).toEqual([]);
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { sourceOffset: 0, onExecutionStarted: expect.any(Function) });
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1", sourceOffset: 0, onExecutionStarted: expect.any(Function) });
|
||||
});
|
||||
|
||||
it("sends Doris STRUCT DDL unchanged without opening the parameter dialog", async () => {
|
||||
@@ -1008,7 +1008,7 @@ SELECT @value AS Message;`;
|
||||
|
||||
expect(execution.showSqlParameterDialog.value).toBe(false);
|
||||
expect(execution.sqlParameterNames.value).toEqual([]);
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(sql, {});
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1" });
|
||||
});
|
||||
|
||||
it("records a later MySQL batch error and skips metadata refresh", async () => {
|
||||
@@ -1287,7 +1287,7 @@ SELECT @value AS Message;`;
|
||||
|
||||
await execution.onDangerConfirm();
|
||||
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { openInNewResultTab: true });
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1", openInNewResultTab: true });
|
||||
});
|
||||
|
||||
it("keeps the active retained result when a new-result dangerous prompt is cancelled", async () => {
|
||||
@@ -1493,7 +1493,7 @@ SELECT @value AS Message;`;
|
||||
|
||||
await execution.onDangerConfirm();
|
||||
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { skipRedisSafetyCheck: false, openInNewResultTab: true });
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1", skipRedisSafetyCheck: false, openInNewResultTab: true });
|
||||
});
|
||||
|
||||
it("distinguishes read-only and mutating Meilisearch REST requests", () => {
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TAB_DRAG_HORIZONTAL_THRESHOLD, useTabDrag } from "@/composables/useTabDrag";
|
||||
|
||||
function createDragHarness(onDrop = vi.fn(() => true)) {
|
||||
const drag = useTabDrag(onDrop);
|
||||
const source = document.createElement("div");
|
||||
source.innerHTML = '<span class="truncate">Source</span>';
|
||||
source.addEventListener("pointerdown", (event) => drag.startDrag(event as PointerEvent, "source"));
|
||||
document.body.appendChild(source);
|
||||
return { drag, source, onDrop };
|
||||
}
|
||||
|
||||
function beginDrag(source: HTMLElement, x = 100, y = 20, pointerType = "mouse", buttons = 1, pointerId = 1) {
|
||||
source.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0, buttons, clientX: x, clientY: y, pointerId, pointerType }));
|
||||
}
|
||||
|
||||
function movePointer(x: number, y = 20, pointerType = "mouse", buttons = 1, pointerId = 1) {
|
||||
document.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, buttons, clientX: x, clientY: y, pointerId, pointerType }));
|
||||
}
|
||||
|
||||
function releasePointer(x = 0, y = 0, pointerId = 1) {
|
||||
document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, clientX: x, clientY: y, pointerId, screenX: x, screenY: y }));
|
||||
}
|
||||
|
||||
function cancelPointer(pointerId = 1) {
|
||||
document.dispatchEvent(new PointerEvent("pointercancel", { bubbles: true, pointerId }));
|
||||
}
|
||||
|
||||
function enterDropTarget(drag: ReturnType<typeof useTabDrag>, tabId = "target", x = 10) {
|
||||
const target = document.createElement("div");
|
||||
target.getBoundingClientRect = () => ({ x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 28, width: 100, height: 28, toJSON: () => ({}) });
|
||||
target.addEventListener("mousemove", (event) => drag.updateTarget(event, tabId));
|
||||
document.body.appendChild(target);
|
||||
target.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: x, clientY: 20 }));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
releasePointer();
|
||||
document.body.innerHTML = "";
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
});
|
||||
|
||||
describe("useTabDrag", () => {
|
||||
it("ignores sub-threshold horizontal movement and vertical pointer jitter", () => {
|
||||
const { drag, source, onDrop } = createDragHarness();
|
||||
beginDrag(source);
|
||||
|
||||
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD - 1, 120);
|
||||
|
||||
expect(drag.state.active).toBe(false);
|
||||
expect(drag.state.suppressClick).toBe(false);
|
||||
expect(onDrop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not suppress the click when a drag gesture has no valid drop target", () => {
|
||||
const { drag, source, onDrop } = createDragHarness();
|
||||
beginDrag(source);
|
||||
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
|
||||
expect(drag.state.active).toBe(true);
|
||||
|
||||
releasePointer();
|
||||
|
||||
expect(onDrop).not.toHaveBeenCalled();
|
||||
expect(drag.state.suppressClick).toBe(false);
|
||||
});
|
||||
|
||||
it("does not suppress the click when the drop callback reports no reorder", () => {
|
||||
const onDrop = vi.fn(() => false);
|
||||
const { drag, source } = createDragHarness(onDrop);
|
||||
beginDrag(source);
|
||||
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
|
||||
enterDropTarget(drag);
|
||||
|
||||
releasePointer();
|
||||
|
||||
expect(onDrop).toHaveBeenCalledWith("source", "target", "before");
|
||||
expect(drag.state.suppressClick).toBe(false);
|
||||
});
|
||||
|
||||
it("suppresses the click only after a completed reorder", () => {
|
||||
const { drag, source, onDrop } = createDragHarness();
|
||||
beginDrag(source);
|
||||
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
|
||||
enterDropTarget(drag, "target", 90);
|
||||
|
||||
releasePointer();
|
||||
|
||||
expect(onDrop).toHaveBeenCalledWith("source", "target", "after");
|
||||
expect(drag.state.suppressClick).toBe(true);
|
||||
|
||||
beginDrag(source);
|
||||
expect(drag.state.suppressClick).toBe(false);
|
||||
});
|
||||
|
||||
it("clears completed-drag click suppression on a later trackpad tap", () => {
|
||||
const { drag, source } = createDragHarness();
|
||||
beginDrag(source);
|
||||
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
|
||||
enterDropTarget(drag, "target", 90);
|
||||
releasePointer();
|
||||
expect(drag.state.suppressClick).toBe(true);
|
||||
|
||||
beginDrag(source, 100, 20, "mouse", 0);
|
||||
|
||||
expect(drag.state.active).toBe(false);
|
||||
expect(drag.state.suppressClick).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores 18px of jitter, matching a typical touchscreen tap reported as generic mouse input", () => {
|
||||
const { drag, source, onDrop } = createDragHarness();
|
||||
beginDrag(source);
|
||||
|
||||
movePointer(100 - 18, 24);
|
||||
|
||||
expect(drag.state.active).toBe(false);
|
||||
expect(drag.state.suppressClick).toBe(false);
|
||||
expect(onDrop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not arm a drag when the primary button is not held", () => {
|
||||
const { drag, source, onDrop } = createDragHarness();
|
||||
beginDrag(source, 100, 20, "mouse", 0);
|
||||
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD, 20, "mouse", 0);
|
||||
|
||||
releasePointer();
|
||||
|
||||
expect(drag.state.active).toBe(false);
|
||||
expect(onDrop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels a pending drag when the primary button is released before movement", () => {
|
||||
const { drag, source, onDrop } = createDragHarness();
|
||||
beginDrag(source);
|
||||
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD, 20, "mouse", 0);
|
||||
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD + 10);
|
||||
|
||||
expect(drag.state.active).toBe(false);
|
||||
expect(onDrop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels an active drag when the primary button is no longer held", () => {
|
||||
const { drag, source, onDrop } = createDragHarness();
|
||||
beginDrag(source);
|
||||
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
|
||||
expect(drag.state.active).toBe(true);
|
||||
|
||||
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD + 10, 20, "mouse", 0);
|
||||
|
||||
expect(drag.state.active).toBe(false);
|
||||
expect(drag.state.draggedId).toBe(null);
|
||||
expect(document.body.style.cursor).toBe("");
|
||||
expect(document.body.style.userSelect).toBe("");
|
||||
expect(onDrop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores movement from a different pointer", () => {
|
||||
const { drag, source, onDrop } = createDragHarness();
|
||||
beginDrag(source, 100, 20, "mouse", 1, 7);
|
||||
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD, 20, "mouse", 1, 8);
|
||||
|
||||
expect(drag.state.active).toBe(false);
|
||||
expect(onDrop).not.toHaveBeenCalled();
|
||||
releasePointer(0, 0, 7);
|
||||
});
|
||||
|
||||
it("never arms a drag for touch input, even past the horizontal threshold", () => {
|
||||
const { drag, source, onDrop } = createDragHarness();
|
||||
beginDrag(source, 100, 20, "touch");
|
||||
|
||||
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD + 20, 20, "touch");
|
||||
|
||||
expect(drag.state.active).toBe(false);
|
||||
expect(drag.state.suppressClick).toBe(false);
|
||||
expect(onDrop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("detaches only when an armed drag is released outside the tab strip", () => {
|
||||
const onDetach = vi.fn(() => true);
|
||||
const drag = useTabDrag(
|
||||
vi.fn(() => true),
|
||||
onDetach,
|
||||
);
|
||||
drag.setDetachBoundsProvider(() => ({ left: 0, top: 0, right: 300, bottom: 40 }) as DOMRect);
|
||||
const source = document.createElement("div");
|
||||
source.innerHTML = '<span class="truncate">Source</span>';
|
||||
source.addEventListener("pointerdown", (event) => drag.startDrag(event as PointerEvent, "source"));
|
||||
document.body.appendChild(source);
|
||||
|
||||
beginDrag(source);
|
||||
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
|
||||
releasePointer(500, 500);
|
||||
|
||||
expect(onDetach).toHaveBeenCalledWith("source", { x: 500, y: 500 });
|
||||
expect(drag.state.active).toBe(false);
|
||||
expect(drag.state.suppressClick).toBe(true);
|
||||
drag.setDetachBoundsProvider(null);
|
||||
});
|
||||
|
||||
it("clears an armed drag when the pointer is cancelled", () => {
|
||||
const { drag, source } = createDragHarness();
|
||||
beginDrag(source);
|
||||
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
|
||||
expect(drag.state.active).toBe(true);
|
||||
|
||||
cancelPointer();
|
||||
|
||||
expect(drag.state.active).toBe(false);
|
||||
expect(drag.state.draggedId).toBe(null);
|
||||
expect(drag.state.suppressClick).toBe(false);
|
||||
});
|
||||
|
||||
it("does not detach when the pointer is released inside the tab strip", () => {
|
||||
const onDetach = vi.fn(() => true);
|
||||
const onDrop = vi.fn(() => false);
|
||||
const drag = useTabDrag(onDrop, onDetach);
|
||||
drag.setDetachBoundsProvider(() => ({ left: 0, top: 0, right: 300, bottom: 40 }) as DOMRect);
|
||||
const source = document.createElement("div");
|
||||
source.innerHTML = '<span class="truncate">Source</span>';
|
||||
source.addEventListener("pointerdown", (event) => drag.startDrag(event as PointerEvent, "source"));
|
||||
document.body.appendChild(source);
|
||||
|
||||
beginDrag(source);
|
||||
movePointer(100 + TAB_DRAG_HORIZONTAL_THRESHOLD);
|
||||
releasePointer();
|
||||
|
||||
expect(onDetach).not.toHaveBeenCalled();
|
||||
expect(onDrop).not.toHaveBeenCalled();
|
||||
drag.setDetachBoundsProvider(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
||||
import type { Ref, WatchSource } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { useToolbarOverflow } from "../useToolbarOverflow";
|
||||
import type { EditorToolbarTier } from "@/lib/tabs/editorToolbarLayout";
|
||||
|
||||
function createHarness(revalidateSources: WatchSource[] = []) {
|
||||
const rootRef = ref<HTMLElement | null>(null);
|
||||
let tierRef: Ref<EditorToolbarTier> | undefined;
|
||||
let measureFn: () => void = () => {};
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
const { tier, measure } = useToolbarOverflow(rootRef, revalidateSources);
|
||||
tierRef = tier;
|
||||
measureFn = measure;
|
||||
return () => h("div");
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.mount(document.createElement("div"));
|
||||
return { rootRef, tierRef: tierRef!, measure: () => measureFn() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake toolbar row whose scrollWidth shrinks once a tier is condensed —
|
||||
* mirroring real rows where controls move into the overflow menu, so the
|
||||
* measure loop can converge instead of running to the max tier.
|
||||
*/
|
||||
function fakeRow(clientWidth: number, contentWidthAtTier: (tier: EditorToolbarTier) => number, tierSource: Ref<EditorToolbarTier>): HTMLElement {
|
||||
const element = document.createElement("div");
|
||||
Object.defineProperty(element, "clientWidth", { value: clientWidth, configurable: true });
|
||||
Object.defineProperty(element, "scrollWidth", { configurable: true, get: () => contentWidthAtTier(tierSource.value) });
|
||||
return element;
|
||||
}
|
||||
|
||||
async function flush(ticks = 5) {
|
||||
for (let i = 0; i < ticks; i += 1) {
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
describe("useToolbarOverflow", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("steps up while the row content overflows and keeps the tier when it merely fits again", async () => {
|
||||
const { rootRef, tierRef, measure } = createHarness();
|
||||
|
||||
rootRef.value = fakeRow(200, (tier) => (tier >= 1 ? 180 : 260), tierRef);
|
||||
await flush();
|
||||
expect(tierRef.value).toBe(1);
|
||||
|
||||
// Widening without enough growth keeps the condensed tier: the row fits
|
||||
// (slack 20px < 48px) and the pane grew 0px since the condensation. The
|
||||
// explicit measure() stands in for the ResizeObserver event that a real
|
||||
// browser fires after the box resize.
|
||||
Object.defineProperty(rootRef.value!, "clientWidth", { value: 220, configurable: true });
|
||||
measure();
|
||||
expect(tierRef.value).toBe(1);
|
||||
});
|
||||
|
||||
it("condenses tier by tier and caps at the max tier while still overflowing", async () => {
|
||||
const { rootRef, tierRef } = createHarness();
|
||||
|
||||
rootRef.value = fakeRow(160, () => 400, tierRef);
|
||||
await flush();
|
||||
expect(tierRef.value).toBe(3);
|
||||
});
|
||||
|
||||
it("steps down only with real slack and meaningful pane growth", async () => {
|
||||
const { rootRef, tierRef, measure } = createHarness();
|
||||
|
||||
rootRef.value = fakeRow(200, (tier) => (tier >= 1 ? 180 : 260), tierRef);
|
||||
await flush();
|
||||
expect(tierRef.value).toBe(1);
|
||||
|
||||
// Grew by 10px only: below the 64px growth hysteresis, no step down even
|
||||
// though the condensed row now has slack.
|
||||
Object.defineProperty(rootRef.value!, "clientWidth", { value: 210, configurable: true });
|
||||
measure();
|
||||
expect(tierRef.value).toBe(1);
|
||||
|
||||
// Grew by 100px with 70px slack: both step-down thresholds pass.
|
||||
Object.defineProperty(rootRef.value!, "clientWidth", { value: 300, configurable: true });
|
||||
measure();
|
||||
expect(tierRef.value).toBe(0);
|
||||
});
|
||||
|
||||
it("re-measures when a revalidate source changes the control set", async () => {
|
||||
const flag = ref(0);
|
||||
const { rootRef, tierRef } = createHarness([() => flag.value]);
|
||||
let contentGrew = false;
|
||||
|
||||
rootRef.value = fakeRow(200, (tier) => (tier >= 1 ? 180 : contentGrew ? 260 : 180), tierRef);
|
||||
await flush();
|
||||
expect(tierRef.value).toBe(0);
|
||||
|
||||
// A conditional control appeared: the content extent grew without the
|
||||
// toolbar box resizing (no ResizeObserver event) — only the source watch
|
||||
// re-measures this.
|
||||
contentGrew = true;
|
||||
flag.value += 1;
|
||||
await flush();
|
||||
expect(tierRef.value).toBe(1);
|
||||
});
|
||||
|
||||
it("starts from the full layout when a row re-attaches instead of inheriting a stale tier", async () => {
|
||||
const { rootRef, tierRef } = createHarness();
|
||||
|
||||
rootRef.value = fakeRow(200, () => 400, tierRef);
|
||||
await flush();
|
||||
expect(tierRef.value).toBe(3);
|
||||
|
||||
rootRef.value = fakeRow(300, () => 230, tierRef);
|
||||
await flush();
|
||||
expect(tierRef.value).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the current tier while the row is unmeasured", async () => {
|
||||
const { rootRef, tierRef } = createHarness();
|
||||
|
||||
rootRef.value = fakeRow(0, () => 0, tierRef);
|
||||
await flush();
|
||||
expect(tierRef.value).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,21 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
||||
const queryStore = useQueryStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
// A data-grid action names the tab that emitted the event, so a queued
|
||||
// or late-routed event cannot fall back to whichever tab is active now.
|
||||
function resolveActionTab(tabId: string | undefined): QueryTab | undefined {
|
||||
if (!tabId) {
|
||||
return activeTab.value;
|
||||
}
|
||||
const fromStore = queryStore.tabs.find((candidate) => candidate.id === tabId);
|
||||
if (fromStore) {
|
||||
return fromStore;
|
||||
}
|
||||
// Hosts may hold the acting tab outside the store array; identity comes
|
||||
// from the captured id either way.
|
||||
return activeTab.value?.id === tabId ? activeTab.value : undefined;
|
||||
}
|
||||
|
||||
function activeQueryTargetOptions(tab: QueryTab) {
|
||||
const executionTarget = queryStore.activeResultExecutionTarget(tab.id);
|
||||
if (!executionTarget) return {};
|
||||
@@ -162,15 +177,15 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
||||
return true;
|
||||
}
|
||||
|
||||
async function onExecuteSql(sql: string) {
|
||||
const tab = activeTab.value;
|
||||
async function onExecuteSql(sql: string, tabId?: string) {
|
||||
const tab = resolveActionTab(tabId);
|
||||
if (!tab) return;
|
||||
queryStore.updateSql(tab.id, sql);
|
||||
await queryStore.executeTabSql(tab.id, sql, { preserveResultDuringExecution: true });
|
||||
}
|
||||
|
||||
async function onReloadData(sql?: string, _searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent) {
|
||||
const tab = activeTab.value;
|
||||
async function onReloadData(tabId: string | undefined, sql?: string, _searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent) {
|
||||
const tab = resolveActionTab(tabId);
|
||||
if (!tab) return;
|
||||
const traceId = uuid().slice(0, 8);
|
||||
const startedAt = performance.now();
|
||||
@@ -328,8 +343,8 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
||||
await queryStore.executeCurrentTab();
|
||||
}
|
||||
|
||||
async function onPaginate(offset: number, limit: number, whereInput?: string, orderBy?: string) {
|
||||
const tab = activeTab.value;
|
||||
async function onPaginate(tabId: string | undefined, offset: number, limit: number, whereInput?: string, orderBy?: string) {
|
||||
const tab = resolveActionTab(tabId);
|
||||
if (!tab) return;
|
||||
const appendResult = settingsStore.editorSettings.infiniteScroll && offset > 0 && offset === tab.result?.rows.length;
|
||||
const appendOptions = appendResult
|
||||
@@ -462,8 +477,8 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
|
||||
});
|
||||
}
|
||||
|
||||
async function onSort(column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode: DataGridSortMode = "database") {
|
||||
const tab = activeTab.value;
|
||||
async function onSort(tabId: string | undefined, column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode: DataGridSortMode = "database") {
|
||||
const tab = resolveActionTab(tabId);
|
||||
if (!tab) return;
|
||||
tab.resultSortColumn = direction ? column : undefined;
|
||||
tab.resultSortColumnIndex = direction ? columnIndex : undefined;
|
||||
|
||||
@@ -43,9 +43,9 @@ const AUTO_SCROLL_EDGE_SIZE = 40;
|
||||
const AUTO_SCROLL_MAX_SPEED = 28;
|
||||
// Trackpad/mouse jitter during a plain click can move the pointer a few pixels between
|
||||
// mousedown and mouseup. Without a minimum drag distance, that jitter reads as an
|
||||
// intentional drag and turns a single click into a multi-cell/row selection. Guards the
|
||||
// same class of gesture as TAB_DRAG_HORIZONTAL_THRESHOLD in useTabDrag.ts, though that one
|
||||
// is tuned higher to also absorb touchscreen tap jitter, which doesn't apply here.
|
||||
// intentional drag and turns a single click into a multi-cell/row selection. Same class
|
||||
// of gesture as the group tab bar's horizontal tab-drag threshold, though that one is
|
||||
// tuned higher to also absorb touchscreen tap jitter, which doesn't apply here.
|
||||
const GRID_SELECTION_DRAG_THRESHOLD_PX = 12;
|
||||
type RowSelectionOperation = "replace" | "add" | "remove";
|
||||
|
||||
|
||||
@@ -34,6 +34,18 @@ const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i;
|
||||
interface SqlExecutionOptions {
|
||||
openInNewResultTab?: boolean;
|
||||
editorViewportRequestId?: number;
|
||||
/** Tab that owns this execution request; resolved once, never re-read from the global active tab. */
|
||||
tabId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Targeting context captured synchronously when an execution request starts.
|
||||
* Async resume points (danger / parameter dialogs, awaits) must keep using it
|
||||
* instead of re-reading the global active tab or active connection.
|
||||
*/
|
||||
interface SqlExecutionContext {
|
||||
tabId: string;
|
||||
connection: ConnectionConfig | undefined;
|
||||
}
|
||||
|
||||
interface TargetSqlExecutionInput {
|
||||
@@ -112,10 +124,10 @@ export function useSqlExecution(deps: {
|
||||
activeTab: ComputedRef<QueryTab | undefined>;
|
||||
activeConnection: ComputedRef<ConnectionConfig | undefined>;
|
||||
executableSql: ComputedRef<string>;
|
||||
resolveExecutableSql?: (snapshot?: SqlExecutionSnapshot) => Promise<string>;
|
||||
resolveExecutableSql?: (snapshot?: SqlExecutionSnapshot, tab?: QueryTab) => Promise<string>;
|
||||
activeOutputView: Ref<"result" | "summary" | "explain" | "chart" | "messages">;
|
||||
blockDangerousRedisCommands?: Ref<boolean>;
|
||||
onMissingDatabase?: () => void;
|
||||
onMissingDatabase?: (tabId?: string) => void;
|
||||
requestDangerConfirmation?: (request: SqlExecutionDangerRequest) => Promise<boolean>;
|
||||
onExecutionStarted?: (editorViewportRequestId: number) => void;
|
||||
onExecutionCancelled?: (editorViewportRequestId: number) => void;
|
||||
@@ -144,19 +156,47 @@ export function useSqlExecution(deps: {
|
||||
const pendingOpenInNewResultTab = ref(false);
|
||||
const pendingSqlParameterEditorViewportRequestId = ref<number | undefined>();
|
||||
const pendingDangerEditorViewportRequestId = ref<number | undefined>();
|
||||
const pendingDangerTabId = ref<string | undefined>();
|
||||
const pendingSqlParameterTabId = ref<string | undefined>();
|
||||
let pendingSqlParameterContinuation: ((sql: string, sourceOffset?: number) => Promise<void> | void) | undefined;
|
||||
|
||||
function cancelEditorViewportRequest(editorViewportRequestId?: number) {
|
||||
if (editorViewportRequestId !== undefined) deps.onExecutionCancelled?.(editorViewportRequestId);
|
||||
}
|
||||
|
||||
async function resolvedExecutableSql(source?: SqlExecutionOverride): Promise<{ sql: string; sourceOffset?: number; editorViewportRequestId?: number }> {
|
||||
const atSetEnabled = resolveSqlVariableSyntaxToggles(settingsStore.editorSettings.sqlVariableSyntaxOverrides, deps.activeConnection.value?.db_type, settingsStore.editorSettings.sqlVariableSubstitutionEnabled).atSet;
|
||||
const databaseType = effectiveDatabaseTypeForConnection(deps.activeConnection.value) ?? deps.activeConnection.value?.db_type;
|
||||
function resolveExecutionTab(tabId: string | undefined): QueryTab | undefined {
|
||||
if (!tabId) {
|
||||
return deps.activeTab.value;
|
||||
}
|
||||
const fromStore = queryStore.tabs.find((tab) => tab.id === tabId);
|
||||
if (fromStore) {
|
||||
return fromStore;
|
||||
}
|
||||
// The host may hold the acting tab outside the store array (multi-db
|
||||
// workers, hosts under test); identity still comes from the captured id.
|
||||
return deps.activeTab.value?.id === tabId ? deps.activeTab.value : undefined;
|
||||
}
|
||||
|
||||
function resolveExecutionConnection(tabId: string | undefined): ConnectionConfig | undefined {
|
||||
const tab = resolveExecutionTab(tabId);
|
||||
return (tab ? connectionStore.getConfig(tab.connectionId) : undefined) ?? deps.activeConnection.value;
|
||||
}
|
||||
|
||||
function captureExecutionContext(tabId?: string): SqlExecutionContext | undefined {
|
||||
const tab = resolveExecutionTab(tabId);
|
||||
if (!tab) {
|
||||
return undefined;
|
||||
}
|
||||
return { tabId: tab.id, connection: resolveExecutionConnection(tabId) };
|
||||
}
|
||||
|
||||
async function resolvedExecutableSql(source: SqlExecutionOverride | undefined, context: SqlExecutionContext): Promise<{ sql: string; sourceOffset?: number; editorViewportRequestId?: number }> {
|
||||
const atSetEnabled = resolveSqlVariableSyntaxToggles(settingsStore.editorSettings.sqlVariableSyntaxOverrides, context.connection?.db_type, settingsStore.editorSettings.sqlVariableSubstitutionEnabled).atSet;
|
||||
const databaseType = effectiveDatabaseTypeForConnection(context.connection) ?? context.connection?.db_type;
|
||||
const expand = (sql: string, declarationSql?: string) => (atSetEnabled ? expandSqlVariables(sql, { declarationSql, databaseType }).sql : sql);
|
||||
if (typeof source === "string") return { sql: expand(source) };
|
||||
|
||||
const resolved = deps.resolveExecutableSql ? await deps.resolveExecutableSql(source) : isSqlExecutionSnapshot(source) ? resolveExecutableSql(source.fullSql, source.selectedSql, { cursorPos: source.cursorPos }) : deps.executableSql.value;
|
||||
const resolved = deps.resolveExecutableSql ? await deps.resolveExecutableSql(source, resolveExecutionTab(context.tabId)) : isSqlExecutionSnapshot(source) ? resolveExecutableSql(source.fullSql, source.selectedSql, { cursorPos: source.cursorPos }) : deps.executableSql.value;
|
||||
const declarationSql = isSqlExecutionSnapshot(source) ? source.fullSql.slice(0, source.selectionTo) : resolved;
|
||||
const sql = expand(resolved, declarationSql);
|
||||
const editorViewportRequestId = isSqlExecutionSnapshot(source) ? source.editorViewportRequestId : undefined;
|
||||
@@ -167,33 +207,49 @@ export function useSqlExecution(deps: {
|
||||
}
|
||||
|
||||
async function tryExecute(sqlOverride?: SqlExecutionOverride, options: SqlExecutionOptions = {}) {
|
||||
const tab = deps.activeTab.value;
|
||||
const { sql, sourceOffset, editorViewportRequestId } = await resolvedExecutableSql(sqlOverride);
|
||||
const executionOptions = { ...options, editorViewportRequestId };
|
||||
if (!tab || !sql.trim()) {
|
||||
const context = captureExecutionContext(options.tabId);
|
||||
if (!context) {
|
||||
cancelEditorViewportRequest(options.editorViewportRequestId);
|
||||
return;
|
||||
}
|
||||
const { sql, sourceOffset, editorViewportRequestId } = await resolvedExecutableSql(sqlOverride, context);
|
||||
const executionOptions = { ...options, editorViewportRequestId, tabId: context.tabId };
|
||||
if (!sql.trim()) {
|
||||
cancelEditorViewportRequest(editorViewportRequestId);
|
||||
return;
|
||||
}
|
||||
if (requiresDatabaseSelection(tab, deps.activeConnection.value, sql)) {
|
||||
deps.onMissingDatabase?.();
|
||||
const tab = resolveExecutionTab(context.tabId);
|
||||
if (!tab) {
|
||||
cancelEditorViewportRequest(editorViewportRequestId);
|
||||
return;
|
||||
}
|
||||
if (supportsSqlTemplateParameters(deps.activeConnection.value, sql) && prepareSqlParameterDialog(sql, sourceOffset, executionOptions)) return;
|
||||
await continueExecute(sql, sourceOffset, executionOptions);
|
||||
if (requiresDatabaseSelection(tab, context.connection, sql)) {
|
||||
deps.onMissingDatabase?.(context.tabId);
|
||||
cancelEditorViewportRequest(editorViewportRequestId);
|
||||
return;
|
||||
}
|
||||
if (supportsSqlTemplateParameters(context.connection, sql) && prepareSqlParameterDialog(sql, sourceOffset, executionOptions)) {
|
||||
return;
|
||||
}
|
||||
await continueExecute(context, sql, sourceOffset, executionOptions);
|
||||
}
|
||||
|
||||
function tryExecuteInNewResultTab(sqlOverride?: SqlExecutionOverride) {
|
||||
return tryExecute(sqlOverride, { openInNewResultTab: true });
|
||||
function tryExecuteInNewResultTab(sqlOverride?: SqlExecutionOverride, options: SqlExecutionOptions = {}) {
|
||||
return tryExecute(sqlOverride, { ...options, openInNewResultTab: true });
|
||||
}
|
||||
|
||||
async function continueExecute(sql: string, sourceOffset?: number, options: SqlExecutionOptions = {}) {
|
||||
if (!(await ensureReadOnlyWriteAccess({ connection: deps.activeConnection.value, sql, source: t("production.sourceSqlEditor") }))) {
|
||||
async function continueExecute(context: SqlExecutionContext, sql: string, sourceOffset?: number, options: SqlExecutionOptions = {}) {
|
||||
const tab = resolveExecutionTab(context.tabId);
|
||||
if (!tab) {
|
||||
cancelEditorViewportRequest(options.editorViewportRequestId);
|
||||
return;
|
||||
}
|
||||
if (!(await ensureReadOnlyWriteAccess({ connection: context.connection, sql, source: t("production.sourceSqlEditor") }))) {
|
||||
cancelEditorViewportRequest(options.editorViewportRequestId);
|
||||
return;
|
||||
}
|
||||
// Redis: block dangerous commands when toggle is on (scan entire batch for highest safety level)
|
||||
if (deps.activeConnection.value?.db_type === "redis" && deps.blockDangerousRedisCommands?.value !== false) {
|
||||
if (context.connection?.db_type === "redis" && deps.blockDangerousRedisCommands?.value !== false) {
|
||||
const commands = sql
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
@@ -221,32 +277,37 @@ export function useSqlExecution(deps: {
|
||||
pendingDangerSourceOffset.value = sourceOffset;
|
||||
pendingOpenInNewResultTab.value = options.openInNewResultTab === true;
|
||||
pendingDangerEditorViewportRequestId.value = options.editorViewportRequestId;
|
||||
pendingDangerTabId.value = context.tabId;
|
||||
suppressDangerConfirm.value = false;
|
||||
showDangerDialog.value = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
const productionAssessment = assessProductionSql(sql, deps.activeConnection.value, deps.activeTab.value?.database);
|
||||
const productionAssessment = assessProductionSql(sql, context.connection, tab.database);
|
||||
if (productionAssessment.active && productionAssessment.isMutation) {
|
||||
// Production writes always need a new explicit decision; editor preferences cannot suppress this gate.
|
||||
const confirmed = await productionSafetyStore.requestConfirmation({
|
||||
sql,
|
||||
connectionName: deps.activeConnection.value?.name,
|
||||
database: deps.activeTab.value?.database,
|
||||
connectionName: context.connection?.name,
|
||||
database: tab.database,
|
||||
productionDatabases: productionAssessment.databases,
|
||||
source: t("production.sourceSqlEditor"),
|
||||
});
|
||||
if (confirmed) await doExecute(sql, sourceOffset, options);
|
||||
else cancelEditorViewportRequest(options.editorViewportRequestId);
|
||||
if (confirmed) {
|
||||
await doExecute(sql, sourceOffset, options);
|
||||
} else {
|
||||
cancelEditorViewportRequest(options.editorViewportRequestId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isDangerousSql(sql, deps.activeConnection.value?.db_type) && settingsStore.editorSettings.confirmDangerousSqlExecution) {
|
||||
if (isDangerousSql(sql, context.connection?.db_type) && settingsStore.editorSettings.confirmDangerousSqlExecution) {
|
||||
dangerSql.value = sql;
|
||||
pendingDangerSql.value = sql;
|
||||
pendingDangerKind.value = "sql";
|
||||
pendingDangerSourceOffset.value = sourceOffset;
|
||||
pendingOpenInNewResultTab.value = options.openInNewResultTab === true;
|
||||
pendingDangerEditorViewportRequestId.value = options.editorViewportRequestId;
|
||||
pendingDangerTabId.value = context.tabId;
|
||||
suppressDangerConfirm.value = false;
|
||||
showDangerDialog.value = true;
|
||||
} else {
|
||||
@@ -255,12 +316,14 @@ export function useSqlExecution(deps: {
|
||||
}
|
||||
|
||||
function prepareSqlParameterDialog(sql: string, sourceOffset?: number, options: SqlExecutionOptions = {}, continuation?: (sql: string, sourceOffset?: number) => Promise<void> | void): boolean {
|
||||
const connection = deps.activeConnection.value;
|
||||
const connection = resolveExecutionConnection(options.tabId);
|
||||
const databaseType = effectiveDatabaseTypeForConnection(connection) ?? connection?.db_type;
|
||||
const toggles = resolveSqlVariableSyntaxToggles(settingsStore.editorSettings.sqlVariableSyntaxOverrides, databaseType, settingsStore.editorSettings.sqlVariableSubstitutionEnabled);
|
||||
const enabledSyntaxes = enabledSqlParameterSyntaxes(toggles);
|
||||
const parameters = extractSqlParameterDescriptors(sql, { databaseType, enabledSyntaxes });
|
||||
if (!parameters.length) return false;
|
||||
if (!parameters.length) {
|
||||
return false;
|
||||
}
|
||||
sqlParameterSourceSql.value = sql;
|
||||
sqlParameterNames.value = parameters;
|
||||
sqlParameterDatabaseType.value = databaseType;
|
||||
@@ -268,16 +331,22 @@ export function useSqlExecution(deps: {
|
||||
pendingSourceOffset.value = sourceOffset;
|
||||
pendingOpenInNewResultTab.value = options.openInNewResultTab === true;
|
||||
pendingSqlParameterEditorViewportRequestId.value = options.editorViewportRequestId;
|
||||
pendingSqlParameterTabId.value = options.tabId;
|
||||
pendingSqlParameterContinuation = continuation;
|
||||
showSqlParameterDialog.value = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function prepareMultiExecute(onReady: (sql: string, sourceOffset?: number) => Promise<void> | void): Promise<boolean> {
|
||||
const tab = deps.activeTab.value;
|
||||
const { sql, sourceOffset } = await resolvedExecutableSql();
|
||||
if (!tab || !sql.trim()) return false;
|
||||
if (supportsSqlTemplateParameters(deps.activeConnection.value, sql) && prepareSqlParameterDialog(sql, sourceOffset, {}, onReady)) return true;
|
||||
const context = captureExecutionContext();
|
||||
if (!context) {
|
||||
return false;
|
||||
}
|
||||
const { sql, sourceOffset } = await resolvedExecutableSql(undefined, context);
|
||||
if (!sql.trim()) return false;
|
||||
if (supportsSqlTemplateParameters(context.connection, sql) && prepareSqlParameterDialog(sql, sourceOffset, { tabId: context.tabId }, onReady)) {
|
||||
return true;
|
||||
}
|
||||
await onReady(sql, sourceOffset);
|
||||
return false;
|
||||
}
|
||||
@@ -300,26 +369,41 @@ export function useSqlExecution(deps: {
|
||||
|
||||
async function doExecute(sql?: string, sourceOffset?: number, options: SqlExecutionOptions = {}) {
|
||||
if (sql === undefined) {
|
||||
({ sql, sourceOffset } = await resolvedExecutableSql());
|
||||
const context = captureExecutionContext(options.tabId);
|
||||
if (!context) {
|
||||
cancelEditorViewportRequest(options.editorViewportRequestId);
|
||||
return;
|
||||
}
|
||||
({ sql, sourceOffset } = await resolvedExecutableSql(undefined, context));
|
||||
}
|
||||
const tab = deps.activeTab.value;
|
||||
if (!tab || !sql.trim()) {
|
||||
const executionTabId = options.tabId ?? deps.activeTab.value?.id;
|
||||
if (!executionTabId || !sql || !sql.trim()) {
|
||||
cancelEditorViewportRequest(options.editorViewportRequestId);
|
||||
return;
|
||||
}
|
||||
const tab = resolveExecutionTab(executionTabId);
|
||||
if (!tab) {
|
||||
cancelEditorViewportRequest(options.editorViewportRequestId);
|
||||
return;
|
||||
}
|
||||
const executionConnection = connectionStore.getConfig(tab.connectionId) ?? deps.activeConnection.value;
|
||||
const executionDatabaseType = executionConnection?.db_type;
|
||||
if (requiresDatabaseSelection(tab, executionConnection, sql)) {
|
||||
deps.onMissingDatabase?.();
|
||||
deps.onMissingDatabase?.(executionTabId);
|
||||
cancelEditorViewportRequest(options.editorViewportRequestId);
|
||||
return;
|
||||
}
|
||||
const statementCount = splitSqlStatementRanges(sql, executionDatabaseType).length;
|
||||
deps.activeOutputView.value = statementCount > 1 ? settingsStore.editorSettings.multiStatementDefaultView : "result";
|
||||
// Output-view switching belongs to the tab the user is looking at — both
|
||||
// when the query starts and when it finishes.
|
||||
if (deps.activeTab.value?.id === executionTabId) {
|
||||
deps.activeOutputView.value = statementCount > 1 ? settingsStore.editorSettings.multiStatementDefaultView : "result";
|
||||
}
|
||||
const connName = executionConnection?.name || "";
|
||||
const start = Date.now();
|
||||
const isRedis = executionDatabaseType === "redis";
|
||||
const producedResult = await queryStore.executeCurrentSql(sql, {
|
||||
tabId: executionTabId,
|
||||
...(isRedis ? { skipRedisSafetyCheck: deps.blockDangerousRedisCommands?.value === false } : {}),
|
||||
...(sourceOffset !== undefined ? { sourceOffset } : {}),
|
||||
...(options.openInNewResultTab ? { openInNewResultTab: true } : {}),
|
||||
@@ -329,15 +413,21 @@ export function useSqlExecution(deps: {
|
||||
cancelEditorViewportRequest(options.editorViewportRequestId);
|
||||
return;
|
||||
}
|
||||
const executionTabStillActive = deps.activeTab.value?.id === tab.id;
|
||||
const executionTabStillActive = deps.activeTab.value?.id === executionTabId;
|
||||
const sqlServerMessageResultIndex = executionDatabaseType === "sqlserver" ? tab.results?.findIndex((result) => result.server_message === true) : undefined;
|
||||
if (sqlServerMessageResultIndex !== undefined && sqlServerMessageResultIndex >= 0) {
|
||||
focusSqlServerDataResult(tab.id, executionDatabaseType, tab);
|
||||
if (executionTabStillActive) deps.activeOutputView.value = "result";
|
||||
if (executionTabStillActive) {
|
||||
deps.activeOutputView.value = "result";
|
||||
}
|
||||
} else if (executionDatabaseType === "sqlserver" && tab.result?.server_message === true) {
|
||||
if (executionTabStillActive) deps.activeOutputView.value = "result";
|
||||
if (executionTabStillActive) {
|
||||
deps.activeOutputView.value = "result";
|
||||
}
|
||||
} else if (tab.result && !tab.result.columns.length && !tab.results?.some((result) => result.columns.length > 0)) {
|
||||
if (executionTabStillActive) deps.activeOutputView.value = statementCount === 1 ? defaultViewForResult(tab.result) : "summary";
|
||||
if (executionTabStillActive) {
|
||||
deps.activeOutputView.value = statementCount === 1 ? defaultViewForResult(tab.result) : "summary";
|
||||
}
|
||||
}
|
||||
const elapsed = Date.now() - start;
|
||||
const failure = firstQueryExecutionError(tab);
|
||||
@@ -529,50 +619,83 @@ export function useSqlExecution(deps: {
|
||||
}
|
||||
}
|
||||
|
||||
function cancelActiveExecution() {
|
||||
const tab = deps.activeTab.value;
|
||||
if (!tab) return;
|
||||
if (tab.isExecuting) void queryStore.cancelTabExecution(tab.id);
|
||||
else if (tab.isExplaining) void queryStore.cancelTabExplain(tab.id);
|
||||
/**
|
||||
* Opens the danger dialog for an App-level flow (AI auto-execution) that has
|
||||
* already made its own gating decision. The acting tab id is captured here so
|
||||
* the confirmation resumes against the requesting tab, not whichever tab is
|
||||
* active when the user confirms.
|
||||
*/
|
||||
function requestDangerConfirmation(sql: string, tabId?: string) {
|
||||
dangerSql.value = sql;
|
||||
pendingDangerSql.value = sql;
|
||||
pendingDangerKind.value = "sql";
|
||||
pendingOpenInNewResultTab.value = false;
|
||||
pendingDangerTabId.value = tabId;
|
||||
suppressDangerConfirm.value = false;
|
||||
showDangerDialog.value = true;
|
||||
}
|
||||
|
||||
function cancelActiveExecution(tabId?: string) {
|
||||
const tab = resolveExecutionTab(tabId);
|
||||
if (!tab) {
|
||||
return;
|
||||
}
|
||||
if (tab.isExecuting) {
|
||||
void queryStore.cancelTabExecution(tab.id);
|
||||
} else if (tab.isExplaining) {
|
||||
void queryStore.cancelTabExplain(tab.id);
|
||||
}
|
||||
}
|
||||
|
||||
function explainReasonMessage(reason: string): string {
|
||||
if (reason === "unsupported") return t("explain.unsupported");
|
||||
if (reason === "unsafe") return t("explain.unsafe");
|
||||
if (reason === "unsupported") {
|
||||
return t("explain.unsupported");
|
||||
}
|
||||
if (reason === "unsafe") {
|
||||
return t("explain.unsafe");
|
||||
}
|
||||
return t("explain.emptySql");
|
||||
}
|
||||
|
||||
async function runExplain(sql: string) {
|
||||
const tab = deps.activeTab.value;
|
||||
async function runExplain(sql: string, context: SqlExecutionContext) {
|
||||
const tab = resolveExecutionTab(context.tabId);
|
||||
if (!tab || !sql.trim()) {
|
||||
toast(t("explain.emptySql"));
|
||||
return;
|
||||
}
|
||||
|
||||
deps.activeOutputView.value = "explain";
|
||||
const connection = deps.activeConnection.value;
|
||||
const databaseType = effectiveDatabaseTypeForConnection(connection) ?? connection?.db_type;
|
||||
if (deps.activeTab.value?.id === tab.id) {
|
||||
deps.activeOutputView.value = "explain";
|
||||
}
|
||||
const databaseType = effectiveDatabaseTypeForConnection(context.connection) ?? context.connection?.db_type;
|
||||
const result = await queryStore.explainTabSql(tab.id, sql, databaseType, explainMode.value);
|
||||
if (!result.ok) {
|
||||
toast(explainReasonMessage(result.reason), 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
const current = deps.activeTab.value;
|
||||
if (current?.explainError) toast(current.explainError, 5000);
|
||||
const current = resolveExecutionTab(context.tabId);
|
||||
if (current?.explainError) {
|
||||
toast(current.explainError, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function tryExplain(sqlOverride?: SqlExecutionOverride) {
|
||||
const tab = deps.activeTab.value;
|
||||
const { sql, sourceOffset } = await resolvedExecutableSql(sqlOverride);
|
||||
if (!tab || !sql.trim()) {
|
||||
async function tryExplain(sqlOverride?: SqlExecutionOverride, options: SqlExecutionOptions = {}) {
|
||||
const context = captureExecutionContext(options.tabId);
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
const { sql, sourceOffset } = await resolvedExecutableSql(sqlOverride, context);
|
||||
if (!sql.trim()) {
|
||||
toast(t("explain.emptySql"));
|
||||
return;
|
||||
}
|
||||
// Resolve SQL template variables exactly like tryExecute so EXPLAIN never runs the raw
|
||||
// placeholders (e.g. `EXPLAIN (FORMAT JSON) SELECT :var;` fails on PostgreSQL).
|
||||
if (supportsSqlTemplateParameters(deps.activeConnection.value, sql) && prepareSqlParameterDialog(sql, sourceOffset, {}, (resolvedSql) => runExplain(resolvedSql))) return;
|
||||
await runExplain(sql);
|
||||
if (supportsSqlTemplateParameters(context.connection, sql) && prepareSqlParameterDialog(sql, sourceOffset, { tabId: context.tabId }, (resolvedSql) => runExplain(resolvedSql, context))) {
|
||||
return;
|
||||
}
|
||||
await runExplain(sql, context);
|
||||
}
|
||||
|
||||
async function onDangerConfirm() {
|
||||
@@ -581,25 +704,33 @@ export function useSqlExecution(deps: {
|
||||
const kind = pendingDangerKind.value;
|
||||
const openInNewResultTab = pendingOpenInNewResultTab.value;
|
||||
const editorViewportRequestId = pendingDangerEditorViewportRequestId.value;
|
||||
const tabId = pendingDangerTabId.value;
|
||||
pendingDangerSql.value = "";
|
||||
pendingDangerSourceOffset.value = undefined;
|
||||
pendingDangerKind.value = "sql";
|
||||
pendingOpenInNewResultTab.value = false;
|
||||
pendingDangerEditorViewportRequestId.value = undefined;
|
||||
pendingDangerTabId.value = undefined;
|
||||
if (suppressDangerConfirm.value && kind === "sql") {
|
||||
settingsStore.updateEditorSettings({ confirmDangerousSqlExecution: false });
|
||||
}
|
||||
suppressDangerConfirm.value = false;
|
||||
if (!(await ensureReadOnlyWriteAccess({ connection: deps.activeConnection.value, sql, source: t("production.sourceSqlEditor") }))) {
|
||||
const tab = resolveExecutionTab(tabId);
|
||||
if (!tab) {
|
||||
return;
|
||||
}
|
||||
const connection = connectionStore.getConfig(tab.connectionId);
|
||||
if (!(await ensureReadOnlyWriteAccess({ connection, sql, source: t("production.sourceSqlEditor") }))) {
|
||||
cancelEditorViewportRequest(editorViewportRequestId);
|
||||
return;
|
||||
}
|
||||
await doExecute(sql, sourceOffset, { openInNewResultTab, editorViewportRequestId });
|
||||
await doExecute(sql, sourceOffset, { openInNewResultTab, editorViewportRequestId, tabId: tab.id });
|
||||
}
|
||||
|
||||
async function onSqlParametersConfirm(sql: string) {
|
||||
const openInNewResultTab = pendingOpenInNewResultTab.value;
|
||||
const editorViewportRequestId = pendingSqlParameterEditorViewportRequestId.value;
|
||||
const tabId = pendingSqlParameterTabId.value;
|
||||
showSqlParameterDialog.value = false;
|
||||
sqlParameterSourceSql.value = "";
|
||||
sqlParameterNames.value = [];
|
||||
@@ -609,10 +740,18 @@ export function useSqlExecution(deps: {
|
||||
pendingSourceOffset.value = undefined;
|
||||
pendingOpenInNewResultTab.value = false;
|
||||
pendingSqlParameterEditorViewportRequestId.value = undefined;
|
||||
pendingSqlParameterTabId.value = undefined;
|
||||
const continuation = pendingSqlParameterContinuation;
|
||||
pendingSqlParameterContinuation = undefined;
|
||||
if (continuation) await continuation(sql, sourceOffset);
|
||||
else await continueExecute(sql, sourceOffset, { openInNewResultTab, editorViewportRequestId });
|
||||
if (continuation) {
|
||||
await continuation(sql, sourceOffset);
|
||||
return;
|
||||
}
|
||||
const context = captureExecutionContext(tabId);
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
await continueExecute(context, sql, sourceOffset, { openInNewResultTab, editorViewportRequestId, tabId: context.tabId });
|
||||
}
|
||||
|
||||
watch(showSqlParameterDialog, (open) => {
|
||||
@@ -625,6 +764,7 @@ export function useSqlExecution(deps: {
|
||||
pendingSourceOffset.value = undefined;
|
||||
pendingOpenInNewResultTab.value = false;
|
||||
pendingSqlParameterEditorViewportRequestId.value = undefined;
|
||||
pendingSqlParameterTabId.value = undefined;
|
||||
pendingSqlParameterContinuation = undefined;
|
||||
cancelEditorViewportRequest(editorViewportRequestId);
|
||||
});
|
||||
@@ -637,6 +777,7 @@ export function useSqlExecution(deps: {
|
||||
pendingDangerKind.value = "sql";
|
||||
pendingOpenInNewResultTab.value = false;
|
||||
pendingDangerEditorViewportRequestId.value = undefined;
|
||||
pendingDangerTabId.value = undefined;
|
||||
suppressDangerConfirm.value = false;
|
||||
cancelEditorViewportRequest(editorViewportRequestId);
|
||||
});
|
||||
@@ -650,6 +791,7 @@ export function useSqlExecution(deps: {
|
||||
tryExecuteInNewResultTab,
|
||||
doExecute,
|
||||
cancelActiveExecution,
|
||||
requestDangerConfirmation,
|
||||
tryExplain,
|
||||
onDangerConfirm,
|
||||
showSqlParameterDialog,
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
import { reactive, readonly } from "vue";
|
||||
|
||||
export type TabDropPosition = "before" | "after";
|
||||
|
||||
interface TabDragState {
|
||||
active: boolean;
|
||||
draggedId: string | null;
|
||||
targetId: string | null;
|
||||
dropPosition: TabDropPosition | null;
|
||||
suppressClick: boolean;
|
||||
startX: number;
|
||||
startY: number;
|
||||
}
|
||||
|
||||
// Many touchscreen digitizers report to the OS/webview as a plain HID mouse
|
||||
// (no distinguishable pointerType), with tap contact-point drift well beyond
|
||||
// what real mouse/trackpad click jitter produces. 24px absorbs that jitter
|
||||
// while still requiring a clearly deliberate drag to reorder a tab.
|
||||
export const TAB_DRAG_HORIZONTAL_THRESHOLD = 24;
|
||||
|
||||
const state = reactive<TabDragState>({
|
||||
active: false,
|
||||
draggedId: null,
|
||||
targetId: null,
|
||||
dropPosition: null,
|
||||
suppressClick: false,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
});
|
||||
|
||||
let pending: {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
pointerId: number;
|
||||
sourceEl: HTMLElement | null;
|
||||
} | null = null;
|
||||
let onDropCallback: ((draggedId: string, targetId: string, position: TabDropPosition) => boolean) | null = null;
|
||||
let onDetachCallback: ((draggedId: string, position: { x: number; y: number }) => boolean) | null = null;
|
||||
let detachBoundsProvider: (() => DOMRect | undefined) | null = null;
|
||||
let dragAxis: () => "horizontal" | "vertical" = () => "horizontal";
|
||||
let ghostEl: HTMLElement | null = null;
|
||||
|
||||
function createGhost(sourceEl: HTMLElement, x: number, y: number) {
|
||||
const ghost = document.createElement("div");
|
||||
const textNode = sourceEl.querySelector(".truncate");
|
||||
ghost.textContent = textNode?.textContent || "";
|
||||
ghost.style.cssText = `
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
opacity: 0.9;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
border-radius: var(--dbx-radius-fixed-6);
|
||||
background: var(--background, #fff);
|
||||
border: 1px solid var(--border, #e5e7eb);
|
||||
max-width: 200px;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
line-height: 28px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
left: ${x + 12}px;
|
||||
top: ${y - 14}px;
|
||||
`;
|
||||
document.body.appendChild(ghost);
|
||||
return ghost;
|
||||
}
|
||||
|
||||
function moveGhost(x: number, y: number) {
|
||||
if (!ghostEl) return;
|
||||
ghostEl.style.left = `${x + 8}px`;
|
||||
ghostEl.style.top = `${y - 14}px`;
|
||||
}
|
||||
|
||||
function removeGhost() {
|
||||
if (ghostEl) {
|
||||
ghostEl.remove();
|
||||
ghostEl = null;
|
||||
}
|
||||
}
|
||||
|
||||
function activateDrag(x: number, y: number) {
|
||||
if (!pending) return;
|
||||
|
||||
activePointerId = pending.pointerId;
|
||||
state.active = true;
|
||||
state.draggedId = pending.id;
|
||||
state.startX = pending.x;
|
||||
state.startY = pending.y;
|
||||
if (pending.sourceEl) {
|
||||
ghostEl = createGhost(pending.sourceEl, x, y);
|
||||
}
|
||||
pending = null;
|
||||
document.body.style.cursor = "grabbing";
|
||||
document.body.style.userSelect = "none";
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
if (!pending && !state.active) return;
|
||||
|
||||
const trackedPointerId = pending?.pointerId ?? activePointerId;
|
||||
if (trackedPointerId !== null && trackedPointerId !== undefined && event.pointerId !== trackedPointerId) return;
|
||||
|
||||
// macOS reports a trackpad tap as a mouse pointer with button=0, but
|
||||
// buttons=0. It is a click gesture, not a held primary-button drag.
|
||||
if ((event.buttons & 1) !== 1) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pending && !state.active) {
|
||||
const delta = dragAxis() === "vertical" ? event.clientY - pending.y : event.clientX - pending.x;
|
||||
if (Math.abs(delta) < TAB_DRAG_HORIZONTAL_THRESHOLD) return;
|
||||
activateDrag(event.clientX, event.clientY);
|
||||
}
|
||||
|
||||
if (state.active) {
|
||||
moveGhost(event.clientX, event.clientY);
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseUp(event: PointerEvent) {
|
||||
const trackedPointerId = pending?.pointerId ?? activePointerId;
|
||||
if (trackedPointerId !== null && trackedPointerId !== undefined && event.pointerId !== trackedPointerId) return;
|
||||
|
||||
state.suppressClick = false;
|
||||
if (state.active && state.draggedId && onDetachCallback && isOutsideDetachBounds(event.clientX, event.clientY)) {
|
||||
const scale = typeof window !== "undefined" && Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0 ? window.devicePixelRatio : 1;
|
||||
state.suppressClick = onDetachCallback(state.draggedId, { x: event.screenX * scale, y: event.screenY * scale });
|
||||
} else if (state.active && state.draggedId && state.targetId && state.dropPosition && onDropCallback) {
|
||||
state.suppressClick = onDropCallback(state.draggedId, state.targetId, state.dropPosition);
|
||||
}
|
||||
reset();
|
||||
}
|
||||
|
||||
function onPointerCancel(event?: Event) {
|
||||
const trackedPointerId = pending?.pointerId ?? activePointerId;
|
||||
if (event && "pointerId" in event && trackedPointerId !== null && trackedPointerId !== undefined && (event as PointerEvent).pointerId !== trackedPointerId) return;
|
||||
|
||||
state.suppressClick = false;
|
||||
reset();
|
||||
}
|
||||
|
||||
function isOutsideDetachBounds(x: number, y: number): boolean {
|
||||
const bounds = detachBoundsProvider?.();
|
||||
return !!bounds && (x < bounds.left || x > bounds.right || y < bounds.top || y > bounds.bottom);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
state.active = false;
|
||||
state.draggedId = null;
|
||||
state.targetId = null;
|
||||
state.dropPosition = null;
|
||||
state.startX = 0;
|
||||
state.startY = 0;
|
||||
activePointerId = null;
|
||||
pending = null;
|
||||
removeGhost();
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
}
|
||||
|
||||
let listenersAttached = false;
|
||||
let activePointerId: number | null = null;
|
||||
|
||||
function ensureListeners() {
|
||||
if (listenersAttached) return;
|
||||
document.addEventListener("pointermove", onPointerMove, true);
|
||||
document.addEventListener("pointerup", onMouseUp, true);
|
||||
document.addEventListener("pointercancel", onPointerCancel, true);
|
||||
window.addEventListener("blur", onPointerCancel, true);
|
||||
listenersAttached = true;
|
||||
}
|
||||
|
||||
export function useTabDrag(onDrop: (draggedId: string, targetId: string, position: TabDropPosition) => boolean, onDetach?: (draggedId: string, position: { x: number; y: number }) => boolean, axis: () => "horizontal" | "vertical" = () => "horizontal") {
|
||||
ensureListeners();
|
||||
onDropCallback = onDrop;
|
||||
onDetachCallback = onDetach ?? null;
|
||||
dragAxis = axis;
|
||||
|
||||
function setDetachBoundsProvider(provider: (() => DOMRect | undefined) | null) {
|
||||
detachBoundsProvider = provider;
|
||||
}
|
||||
|
||||
function startDrag(event: PointerEvent, tabId: string) {
|
||||
if (event.button !== 0) return;
|
||||
// Consume click suppression on the next primary-button gesture even when
|
||||
// macOS reports a trackpad tap with buttons=0 and no drag can be armed.
|
||||
state.suppressClick = false;
|
||||
if ((event.buttons & 1) !== 1) return;
|
||||
// Touch input has no reliable equivalent of mouse jitter: a real finger's
|
||||
// contact point commonly drifts well past TAB_DRAG_HORIZONTAL_THRESHOLD
|
||||
// during an ordinary tap, which would misread the tap as a drag. Skip
|
||||
// arming the drag for touch entirely and let the tab strip's native
|
||||
// horizontal scroll handle the gesture instead.
|
||||
if (event.pointerType === "touch") return;
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest("button, input, [data-tab-title-input]")) return;
|
||||
const el = (event.currentTarget as HTMLElement) || null;
|
||||
pending = {
|
||||
id: tabId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
pointerId: event.pointerId,
|
||||
sourceEl: el,
|
||||
};
|
||||
}
|
||||
|
||||
function updateTarget(event: MouseEvent, tabId: string) {
|
||||
if (!state.active || tabId === state.draggedId) {
|
||||
if (state.targetId === tabId) {
|
||||
state.targetId = null;
|
||||
state.dropPosition = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
state.targetId = tabId;
|
||||
|
||||
const el = event.currentTarget as HTMLElement;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const vertical = axis() === "vertical";
|
||||
const offset = vertical ? event.clientY - rect.top : event.clientX - rect.left;
|
||||
const size = vertical ? rect.height : rect.width;
|
||||
state.dropPosition = offset < size / 2 ? "before" : "after";
|
||||
}
|
||||
|
||||
function clearTarget(tabId: string) {
|
||||
if (state.targetId === tabId) {
|
||||
state.targetId = null;
|
||||
state.dropPosition = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: readonly(state),
|
||||
startDrag,
|
||||
updateTarget,
|
||||
clearTarget,
|
||||
setDetachBoundsProvider,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { nextTick, onUnmounted, ref, watch, type Ref, type WatchSource } from "vue";
|
||||
import { resolveNextEditorToolbarTier, type EditorToolbarTier } from "@/lib/tabs/editorToolbarLayout";
|
||||
|
||||
/**
|
||||
* Measured progressive condensation for single-row toolbars — the same
|
||||
* mechanism as the SQL editor toolbar (resolveNextEditorToolbarTier): while
|
||||
* the row's content overflows its available width the tier steps up (moving
|
||||
* controls into an overflow menu); it only steps back down when there is real
|
||||
* slack AND the pane has grown meaningfully since the last condensation, so a
|
||||
* static narrow layout can never oscillate.
|
||||
*
|
||||
* Measurement contract: the toolbar row must be `overflow-hidden` with
|
||||
* non-shrinking (shrink-0) or floored (min-w-*) children, so scrollWidth
|
||||
* reliably exceeds clientWidth once nothing can shrink any further. Pure
|
||||
* `truncate` children without a floor collapse to zero width before the row
|
||||
* ever reports overflow, which would silently disable condensation.
|
||||
*
|
||||
* @param rootRef the toolbar row element
|
||||
* @param revalidateSources reactive sources whose change alters the row's
|
||||
* visible control set (active tab, feature flags, conditional controls);
|
||||
* each change re-measures, because hiding/restoring controls changes the
|
||||
* content extent without resizing the toolbar box.
|
||||
*/
|
||||
export function useToolbarOverflow(rootRef: Ref<HTMLElement | null>, revalidateSources: WatchSource[] = []) {
|
||||
const tier = ref<EditorToolbarTier>(0);
|
||||
// Available width when the current tier was condensed into; anchors the
|
||||
// step-down hysteresis so a static narrow layout cannot oscillate.
|
||||
const condensedAtWidth = ref(0);
|
||||
let resizeObserver: ResizeObserver | undefined;
|
||||
|
||||
function measure() {
|
||||
const element = rootRef.value;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
const next = resolveNextEditorToolbarTier({
|
||||
tier: tier.value,
|
||||
availableWidth: element.clientWidth,
|
||||
contentWidth: element.scrollWidth,
|
||||
condensedAtWidth: condensedAtWidth.value,
|
||||
});
|
||||
if (next !== tier.value) {
|
||||
if (next > tier.value) {
|
||||
condensedAtWidth.value = element.clientWidth;
|
||||
}
|
||||
tier.value = next;
|
||||
}
|
||||
}
|
||||
|
||||
// Hiding or restoring controls changes the row content without resizing the
|
||||
// toolbar box, so every tier change re-measures until the row settles.
|
||||
watch(tier, () => {
|
||||
void nextTick(measure);
|
||||
});
|
||||
|
||||
if (revalidateSources.length > 0) {
|
||||
watch(revalidateSources, () => {
|
||||
void nextTick(measure);
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
rootRef,
|
||||
(element) => {
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = undefined;
|
||||
// A freshly attached row (mode switch, tab remount) starts from the full
|
||||
// layout and converges from real measurements instead of inheriting a
|
||||
// stale tier from whatever was previously measured.
|
||||
tier.value = 0;
|
||||
condensedAtWidth.value = 0;
|
||||
if (element && typeof ResizeObserver !== "undefined") {
|
||||
resizeObserver = new ResizeObserver(measure);
|
||||
resizeObserver.observe(element);
|
||||
}
|
||||
void nextTick(measure);
|
||||
},
|
||||
{ flush: "post" },
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = undefined;
|
||||
});
|
||||
|
||||
return { tier, measure };
|
||||
}
|
||||
@@ -142,6 +142,7 @@ export default {
|
||||
changePasswordDescription: "Enter your current password and choose a new one",
|
||||
},
|
||||
toolbar: {
|
||||
moreActions: "More actions",
|
||||
newConnection: "New Connection",
|
||||
newQuery: "New Query",
|
||||
execute: "Execute",
|
||||
@@ -1425,6 +1426,7 @@ export default {
|
||||
},
|
||||
tabs: {
|
||||
sql: "SQL",
|
||||
emptyGroup: "No open tabs",
|
||||
table: "Table",
|
||||
tableData: "Table Data",
|
||||
redis: "Redis",
|
||||
@@ -3041,6 +3043,10 @@ export default {
|
||||
refreshTab: "Refresh Data",
|
||||
renameTab: "Rename Tab",
|
||||
duplicateTab: "Duplicate Tab",
|
||||
splitRight: "Split Right",
|
||||
splitDown: "Split Down",
|
||||
changeOrientation: "Change Split Direction",
|
||||
unsplit: "Unsplit",
|
||||
compactTabTitle: "Shorten Tab Titles",
|
||||
fullTabTitle: "Show Full Tab Titles",
|
||||
closeTab: "Close Tab",
|
||||
|
||||
@@ -144,6 +144,7 @@ export default withEnglishFallback({
|
||||
changePasswordDescription: "Ingresa tu contraseña actual y elige una nueva",
|
||||
},
|
||||
toolbar: {
|
||||
moreActions: "Más acciones",
|
||||
newConnection: "Nueva conexión",
|
||||
newQuery: "Nueva consulta",
|
||||
execute: "Ejecutar",
|
||||
@@ -1407,6 +1408,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
tabs: {
|
||||
sql: "SQL",
|
||||
emptyGroup: "No hay pestañas abiertas",
|
||||
table: "Tabla",
|
||||
tableData: "Datos de tabla",
|
||||
redis: "Redis",
|
||||
@@ -1462,6 +1464,7 @@ export default withEnglishFallback({
|
||||
scrollLeft: "Desplazar pestañas a la izquierda",
|
||||
scrollRight: "Desplazar pestañas a la derecha",
|
||||
openTabs: "Pestañas abiertas",
|
||||
openInNewWindow: "Abrir en una ventana nueva",
|
||||
searchOpenTabs: "Buscar pestañas abiertas...",
|
||||
collapseTabBar: "Contraer barra de pestañas",
|
||||
expandTabBar: "Expandir barra de pestañas",
|
||||
@@ -2958,6 +2961,10 @@ export default withEnglishFallback({
|
||||
refreshTab: "Actualizar datos",
|
||||
renameTab: "Renombrar pestaña",
|
||||
duplicateTab: "Duplicar pestaña",
|
||||
splitRight: "Dividir a la derecha",
|
||||
splitDown: "Dividir hacia abajo",
|
||||
changeOrientation: "Cambiar dirección de división",
|
||||
unsplit: "Anular división",
|
||||
compactTabTitle: "Acortar títulos de pestañas",
|
||||
fullTabTitle: "Mostrar títulos completos de pestañas",
|
||||
closeTab: "Cerrar pestaña",
|
||||
|
||||
@@ -143,6 +143,7 @@ export default withEnglishFallback({
|
||||
changePasswordDescription: "Inserisci la password attuale e scegline una nuova",
|
||||
},
|
||||
toolbar: {
|
||||
moreActions: "Altre azioni",
|
||||
newConnection: "Nuova Connessione",
|
||||
newQuery: "Nuova Query",
|
||||
execute: "Esegui",
|
||||
@@ -1405,6 +1406,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
tabs: {
|
||||
sql: "SQL",
|
||||
emptyGroup: "Nessuna scheda aperta",
|
||||
table: "Tabella",
|
||||
tableData: "Dati Tabella",
|
||||
redis: "Redis",
|
||||
@@ -1460,6 +1462,7 @@ export default withEnglishFallback({
|
||||
scrollLeft: "Scorri schede a sinistra",
|
||||
scrollRight: "Scorri schede a destra",
|
||||
openTabs: "Schede aperte",
|
||||
openInNewWindow: "Apri in una nuova finestra",
|
||||
searchOpenTabs: "Cerca schede aperte...",
|
||||
collapseTabBar: "Comprimi barra delle schede",
|
||||
expandTabBar: "Espandi barra delle schede",
|
||||
@@ -2956,6 +2959,10 @@ export default withEnglishFallback({
|
||||
refreshTab: "Aggiorna Dati",
|
||||
renameTab: "Rinomina Scheda",
|
||||
duplicateTab: "Duplica Scheda",
|
||||
splitRight: "Dividi a destra",
|
||||
splitDown: "Dividi in basso",
|
||||
changeOrientation: "Cambia direzione di divisione",
|
||||
unsplit: "Annulla divisione",
|
||||
compactTabTitle: "Accorcia Titoli Schede",
|
||||
fullTabTitle: "Mostra titoli completi delle schede",
|
||||
closeTab: "Chiudi Scheda",
|
||||
|
||||
@@ -144,6 +144,7 @@ export default withEnglishFallback({
|
||||
changePasswordDescription: "現在のパスワードと新しいパスワードを入力してください",
|
||||
},
|
||||
toolbar: {
|
||||
moreActions: "その他の操作",
|
||||
newConnection: "新しい接続",
|
||||
newQuery: "新しいクエリ",
|
||||
execute: "実行",
|
||||
@@ -1426,6 +1427,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
tabs: {
|
||||
sql: "SQL",
|
||||
emptyGroup: "開いているタブはありません",
|
||||
table: "テーブル",
|
||||
tableData: "テーブルデータ",
|
||||
redis: "Redis",
|
||||
@@ -1479,6 +1481,7 @@ export default withEnglishFallback({
|
||||
scrollLeft: "タブを左にスクロール",
|
||||
scrollRight: "タブを右にスクロール",
|
||||
openTabs: "開いているタブ",
|
||||
openInNewWindow: "新しいウィンドウで開く",
|
||||
searchOpenTabs: "開いているタブを検索...",
|
||||
collapseTabBar: "タブバーを折りたたむ",
|
||||
expandTabBar: "タブバーを展開",
|
||||
@@ -2980,6 +2983,10 @@ export default withEnglishFallback({
|
||||
refreshTab: "データを更新",
|
||||
renameTab: "タブ名を変更",
|
||||
duplicateTab: "タブを複製",
|
||||
splitRight: "右に分割",
|
||||
splitDown: "下に分割",
|
||||
changeOrientation: "分割方向を変更",
|
||||
unsplit: "分割を解除",
|
||||
compactTabTitle: "タブタイトルを短縮",
|
||||
fullTabTitle: "タブの完全なタイトルを表示",
|
||||
closeTab: "タブを閉じる",
|
||||
|
||||
@@ -143,6 +143,7 @@ export default withEnglishFallback({
|
||||
changePasswordDescription: "현재 비밀번호를 입력하고 새 비밀번호를 선택하세요",
|
||||
},
|
||||
toolbar: {
|
||||
moreActions: "더 많은 작업",
|
||||
newConnection: "새 연결",
|
||||
newQuery: "새 쿼리",
|
||||
execute: "실행",
|
||||
@@ -1308,6 +1309,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
tabs: {
|
||||
sql: "SQL",
|
||||
emptyGroup: "열려 있는 탭 없음",
|
||||
table: "테이블",
|
||||
tableData: "테이블 데이터",
|
||||
redis: "Redis",
|
||||
@@ -1366,6 +1368,7 @@ export default withEnglishFallback({
|
||||
scrollLeft: "탭을 왼쪽으로 스크롤",
|
||||
scrollRight: "탭을 오른쪽으로 스크롤",
|
||||
openTabs: "열린 탭",
|
||||
openInNewWindow: "새 창에서 열기",
|
||||
searchOpenTabs: "열린 탭 검색...",
|
||||
collapseTabBar: "탭 표시줄 접기",
|
||||
expandTabBar: "탭 표시줄 펼치기",
|
||||
@@ -2850,6 +2853,10 @@ export default withEnglishFallback({
|
||||
refreshTab: "데이터 새로고침",
|
||||
renameTab: "탭 이름 변경",
|
||||
duplicateTab: "탭 복제",
|
||||
splitRight: "오른쪽으로 분할",
|
||||
splitDown: "아래로 분할",
|
||||
changeOrientation: "분할 방향 변경",
|
||||
unsplit: "분할 해제",
|
||||
compactTabTitle: "탭 제목 줄이기",
|
||||
fullTabTitle: "전체 탭 제목 표시",
|
||||
closeTab: "탭 닫기",
|
||||
|
||||
@@ -144,6 +144,7 @@ export default withEnglishFallback({
|
||||
changePasswordDescription: "Digite sua senha atual e escolha uma nova",
|
||||
},
|
||||
toolbar: {
|
||||
moreActions: "Mais ações",
|
||||
newConnection: "Nova Conexão",
|
||||
newQuery: "Nova Consulta",
|
||||
execute: "Executar",
|
||||
@@ -1406,6 +1407,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
tabs: {
|
||||
sql: "SQL",
|
||||
emptyGroup: "Nenhuma aba aberta",
|
||||
table: "Tabela",
|
||||
tableData: "Dados da Tabela",
|
||||
redis: "Redis",
|
||||
@@ -1462,6 +1464,7 @@ export default withEnglishFallback({
|
||||
scrollLeft: "Rolar abas para a esquerda",
|
||||
scrollRight: "Rolar abas para a direita",
|
||||
openTabs: "Abas abertas",
|
||||
openInNewWindow: "Abrir em nova janela",
|
||||
searchOpenTabs: "Pesquisar abas abertas...",
|
||||
collapseTabBar: "Recolher barra de abas",
|
||||
expandTabBar: "Expandir barra de abas",
|
||||
@@ -2958,6 +2961,10 @@ export default withEnglishFallback({
|
||||
refreshTab: "Atualizar Dados",
|
||||
renameTab: "Renomear Aba",
|
||||
duplicateTab: "Duplicar Aba",
|
||||
splitRight: "Dividir à direita",
|
||||
splitDown: "Dividir para baixo",
|
||||
changeOrientation: "Alterar direção da divisão",
|
||||
unsplit: "Desfazer divisão",
|
||||
compactTabTitle: "Encurtar Títulos das Abas",
|
||||
fullTabTitle: "Mostrar títulos completos das abas",
|
||||
closeTab: "Fechar Aba",
|
||||
|
||||
@@ -143,6 +143,7 @@ export default withEnglishFallback({
|
||||
changePasswordDescription: "Mevcut parolanızı girin ve yeni bir parola seçin",
|
||||
},
|
||||
toolbar: {
|
||||
moreActions: "Diğer İşlemler",
|
||||
newConnection: "Yeni Bağlantı",
|
||||
newQuery: "Yeni Sorgu",
|
||||
execute: "Çalıştır",
|
||||
@@ -1426,6 +1427,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
tabs: {
|
||||
sql: "SQL",
|
||||
emptyGroup: "Açık Sekme Yok",
|
||||
table: "Tablo",
|
||||
tableData: "Tablo Verisi",
|
||||
redis: "Redis",
|
||||
@@ -3042,6 +3044,10 @@ export default withEnglishFallback({
|
||||
refreshTab: "Verileri Yenile",
|
||||
renameTab: "Sekmeyi Yeniden Adlandır",
|
||||
duplicateTab: "Sekmeyi Çoğalt",
|
||||
splitRight: "Sağa Böl",
|
||||
splitDown: "Aşağı Böl",
|
||||
changeOrientation: "Bölme Yönünü Değiştir",
|
||||
unsplit: "Bölmeyi Kaldır",
|
||||
compactTabTitle: "Sekme Başlıklarını Kısalt",
|
||||
fullTabTitle: "Tam Sekme Başlıklarını Göster",
|
||||
closeTab: "Sekmeyi Kapat",
|
||||
|
||||
@@ -67,6 +67,7 @@ export default withEnglishFallback({
|
||||
changePasswordDescription: "输入当前密码并设置新密码",
|
||||
},
|
||||
toolbar: {
|
||||
moreActions: "更多操作",
|
||||
newConnection: "新建连接",
|
||||
newQuery: "新建查询",
|
||||
execute: "执行",
|
||||
@@ -1349,6 +1350,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
tabs: {
|
||||
sql: "SQL",
|
||||
emptyGroup: "暂无打开的页签",
|
||||
table: "表",
|
||||
tableData: "数据表",
|
||||
redis: "Redis",
|
||||
@@ -2964,6 +2966,10 @@ export default withEnglishFallback({
|
||||
refreshTab: "刷新数据",
|
||||
renameTab: "重命名标签页",
|
||||
duplicateTab: "复制标签页",
|
||||
splitRight: "拆分并右移",
|
||||
splitDown: "拆分并下移",
|
||||
changeOrientation: "更改拆分器方向",
|
||||
unsplit: "取消拆分",
|
||||
compactTabTitle: "缩短标签标题",
|
||||
fullTabTitle: "完整标签标题",
|
||||
closeTab: "关闭标签页",
|
||||
|
||||
@@ -144,6 +144,7 @@ export default withEnglishFallback({
|
||||
changePasswordDescription: "輸入目前密碼並設定新密碼",
|
||||
},
|
||||
toolbar: {
|
||||
moreActions: "更多操作",
|
||||
newConnection: "建立連線",
|
||||
newQuery: "建立查詢",
|
||||
execute: "執行",
|
||||
@@ -1405,6 +1406,7 @@ export default withEnglishFallback({
|
||||
},
|
||||
tabs: {
|
||||
sql: "SQL",
|
||||
emptyGroup: "暫無開啟的頁籤",
|
||||
table: "資料表",
|
||||
tableData: "資料表",
|
||||
redis: "Redis",
|
||||
@@ -1461,6 +1463,7 @@ export default withEnglishFallback({
|
||||
scrollLeft: "向左捲動分頁",
|
||||
scrollRight: "向右捲動分頁",
|
||||
openTabs: "已開啟的分頁",
|
||||
openInNewWindow: "在新視窗中開啟",
|
||||
searchOpenTabs: "搜尋已開啟的分頁...",
|
||||
collapseTabBar: "收合分頁列",
|
||||
expandTabBar: "展開分頁列",
|
||||
@@ -2955,6 +2958,10 @@ export default withEnglishFallback({
|
||||
refreshTab: "重新整理資料",
|
||||
renameTab: "重新命名分頁",
|
||||
duplicateTab: "複製分頁",
|
||||
splitRight: "拆分並右移",
|
||||
splitDown: "拆分並下移",
|
||||
changeOrientation: "變更分割方向",
|
||||
unsplit: "取消分割",
|
||||
compactTabTitle: "縮短標籤標題",
|
||||
fullTabTitle: "完整分頁標題",
|
||||
closeTab: "關閉分頁",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createQueryEditorExecutionViewportOwnership, isQueryEditorPositionVisib
|
||||
const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8");
|
||||
const contentAreaSource = readFileSync(new URL("../../../components/layout/ContentArea.vue", import.meta.url), "utf8");
|
||||
const editorToolbarSource = readFileSync(new URL("../../../components/layout/EditorToolbar.vue", import.meta.url), "utf8");
|
||||
const editorGroupSource = readFileSync(new URL("../../../components/layout/EditorGroup.vue", import.meta.url), "utf8");
|
||||
const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8");
|
||||
const sqlExecutionSource = readFileSync(new URL("../../../composables/useSqlExecution.ts", import.meta.url), "utf8");
|
||||
const queryStoreSource = readFileSync(new URL("../../../stores/queryStore.ts", import.meta.url), "utf8");
|
||||
@@ -56,14 +57,21 @@ describe("QueryEditor execution routing", () => {
|
||||
expect(queryEditorSource).toContain("return sqlExecutionSnapshotFromView(currentView);");
|
||||
expect(contentAreaSource).toContain("function captureQueryEditorExecutionSnapshot()");
|
||||
expect(contentAreaSource).toContain("queryEditorRef.value?.captureExecutionSnapshot();");
|
||||
expect(appSource).toContain("const pendingToolbarExecutionSnapshot = ref<SqlExecutionSnapshot>();");
|
||||
expect(appSource).toContain("pendingToolbarExecutionSnapshot.value = contentAreaRef.value?.captureQueryEditorExecutionSnapshot?.();");
|
||||
expect(appSource).toContain("const pendingToolbarExecutionSnapshot = ref<SqlExecutionSnapshot & { tabId?: string }>();");
|
||||
expect(appSource).toContain("pendingToolbarExecutionSnapshot.value = snapshot ? { ...snapshot, tabId: activeTab.value?.id } : undefined;");
|
||||
expect(appSource).toContain('if (source === "pointer")');
|
||||
expect(appSource).toContain("pendingToolbarExecutionSnapshot.value = undefined;");
|
||||
expect(appSource).toContain("void tryExecute(snapshot);");
|
||||
expect(appSource).toContain('@execute-pointer-down="captureActiveEditorExecutionSnapshot()"');
|
||||
expect(appSource).toContain('@execute="requestActiveEditorExecute($event)"');
|
||||
expect(appSource).toContain("void tryExecute(snapshot, { tabId: snapshot.tabId ?? activeTab.value?.id });");
|
||||
expect(appSource).toContain('@execute="(tabId: string, override?: SqlExecutionOverride) => tryExecute(override, { tabId })"');
|
||||
// Per-group toolbars call back into App-owned orchestration via injection.
|
||||
expect(appSource).toContain("provide(EDITOR_TOOLBAR_ACTIONS, {");
|
||||
expect(appSource).toContain("captureExecutionSnapshot: captureActiveEditorExecutionSnapshot,");
|
||||
expect(appSource).toContain("toolbarExecute: requestActiveEditorExecute,");
|
||||
expect(editorGroupSource).toContain('@execute-pointer-down="toolbar.captureExecutionSnapshot()"');
|
||||
expect(editorGroupSource).toContain('@toolbar-execute="toolbar.toolbarExecute($event)"');
|
||||
expect(editorToolbarSource).toContain("function onExecutePointerDown(event: MouseEvent)");
|
||||
expect(editorToolbarSource).toContain('emit("toolbarExecute", event.detail > 0 ? "pointer" : "keyboard")');
|
||||
expect(editorToolbarSource).not.toContain('emit("execute", event.detail > 0 ? "pointer" : "keyboard")');
|
||||
});
|
||||
|
||||
it("uses the opt-in blank-line fallback and otherwise reports the missing cursor statement", () => {
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("QueryEditor auto focus wiring", () => {
|
||||
});
|
||||
|
||||
it("enables auto focus for query tabs", () => {
|
||||
expect(contentAreaSource).toMatch(/<QueryEditor[\s\S]*?\sauto-focus\s[\s\S]*?:model-value="activeTab\.sql"/);
|
||||
expect(contentAreaSource).toMatch(/<QueryEditor[\s\S]*?:\s*auto-focus="autoFocus !== false"\s[\s\S]*?:model-value="activeTab\.sql"/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8");
|
||||
const useSqlExecutionSource = readFileSync(new URL("../../../composables/useSqlExecution.ts", import.meta.url), "utf8");
|
||||
|
||||
describe("toolbar explain routing", () => {
|
||||
it("passes the tab id as the execution context, not as SQL", () => {
|
||||
// Regression: the injected action once called tryExplain(tabId), whose
|
||||
// first parameter is sqlOverride — the id silently typed as SQL and the
|
||||
// EXPLAIN button sent the tab UUID to the backend.
|
||||
expect(appSource).toContain("explain: (tabId: string) => tryExplain(undefined, { tabId }),");
|
||||
expect(appSource).not.toContain("explain: (tabId: string) => tryExplain(tabId),");
|
||||
});
|
||||
|
||||
it("keeps tryExplain's first parameter an SQL override", () => {
|
||||
expect(useSqlExecutionSource).toContain("async function tryExplain(sqlOverride?: SqlExecutionOverride, options: SqlExecutionOptions = {})");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EDITOR_TOOLBAR_MAX_TIER, EDITOR_TOOLBAR_STEP_DOWN_MIN_GROWTH_PX, EDITOR_TOOLBAR_STEP_DOWN_SLACK_PX, resolveNextEditorToolbarTier } from "@/lib/tabs/editorToolbarLayout";
|
||||
|
||||
describe("editor toolbar measured condensation", () => {
|
||||
it("steps up one tier while the content overflows", () => {
|
||||
expect(resolveNextEditorToolbarTier({ tier: 0, availableWidth: 400, contentWidth: 700 })).toBe(1);
|
||||
expect(resolveNextEditorToolbarTier({ tier: 1, availableWidth: 400, contentWidth: 450 })).toBe(2);
|
||||
expect(resolveNextEditorToolbarTier({ tier: 2, availableWidth: 400, contentWidth: 420 })).toBe(3);
|
||||
});
|
||||
|
||||
it("holds at the maximum tier when even the condensed row overflows", () => {
|
||||
expect(resolveNextEditorToolbarTier({ tier: EDITOR_TOOLBAR_MAX_TIER, availableWidth: 200, contentWidth: 400 })).toBe(EDITOR_TOOLBAR_MAX_TIER);
|
||||
});
|
||||
|
||||
it("holds the current tier while the row fits without real slack", () => {
|
||||
expect(resolveNextEditorToolbarTier({ tier: 1, availableWidth: 500, contentWidth: 480 })).toBe(1);
|
||||
expect(resolveNextEditorToolbarTier({ tier: 1, availableWidth: 500, contentWidth: 500 - EDITOR_TOOLBAR_STEP_DOWN_SLACK_PX + 1 })).toBe(1);
|
||||
});
|
||||
|
||||
it("steps down only with slack and real pane growth since the last condensation", () => {
|
||||
const grown = 400 + EDITOR_TOOLBAR_STEP_DOWN_MIN_GROWTH_PX + 10;
|
||||
expect(resolveNextEditorToolbarTier({ tier: 2, availableWidth: grown, contentWidth: grown - EDITOR_TOOLBAR_STEP_DOWN_SLACK_PX - 10, condensedAtWidth: 400 })).toBe(1);
|
||||
});
|
||||
|
||||
it("never oscillates: slack alone without pane growth keeps the condensed tier", () => {
|
||||
// The row fits with plenty of slack, but the pane barely grew since the
|
||||
// toolbar condensed at this width — stepping down would overflow again.
|
||||
expect(resolveNextEditorToolbarTier({ tier: 2, availableWidth: 410, contentWidth: 350, condensedAtWidth: 400 })).toBe(2);
|
||||
expect(resolveNextEditorToolbarTier({ tier: 2, availableWidth: 410, contentWidth: 410 - EDITOR_TOOLBAR_STEP_DOWN_SLACK_PX - 1, condensedAtWidth: 400 })).toBe(2);
|
||||
});
|
||||
|
||||
it("allows stepping down immediately when the toolbar was never condensed", () => {
|
||||
expect(resolveNextEditorToolbarTier({ tier: 1, availableWidth: 900, contentWidth: 500 })).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the current tier for unmeasured or invalid input", () => {
|
||||
expect(resolveNextEditorToolbarTier({ tier: 1, availableWidth: 0, contentWidth: 0 })).toBe(1);
|
||||
expect(resolveNextEditorToolbarTier({ tier: 2, availableWidth: Number.NaN, contentWidth: 100 })).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseTabDragPayload, serializeTabDragPayload, TAB_DRAG_MIME } from "@/lib/tabs/tabDrag";
|
||||
|
||||
describe("tab drag payload", () => {
|
||||
it("serializes and parses a valid payload", () => {
|
||||
const raw = serializeTabDragPayload({ tabId: "tab-1", sourceGroupId: "group-1" });
|
||||
expect(raw).toContain(TAB_DRAG_MIME);
|
||||
expect(parseTabDragPayload(raw)).toEqual({ mime: TAB_DRAG_MIME, tabId: "tab-1", sourceGroupId: "group-1" });
|
||||
});
|
||||
|
||||
it("rejects malformed payloads", () => {
|
||||
expect(parseTabDragPayload("not-json")).toBeNull();
|
||||
expect(parseTabDragPayload(JSON.stringify({ tabId: "tab-1" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects payloads carrying a foreign MIME discriminator", () => {
|
||||
expect(parseTabDragPayload(JSON.stringify({ mime: "application/json", tabId: "tab-1", sourceGroupId: "group-1" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("exposes the custom MIME type", () => {
|
||||
expect(TAB_DRAG_MIME).toBe("application/x-dbx-tab");
|
||||
});
|
||||
});
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCloseAllTabMenuItem, createCloseLeftTabMenuItem, createCloseOtherTabMenuItem, createCloseRightTabMenuItem, createCloseTabMenuItem, createLocateTabMenuItem, createPinTabMenuItem, createRenameDuplicateTabItems } from "@/lib/tabs/tabMenu";
|
||||
|
||||
const t = (key: string) => key;
|
||||
|
||||
describe("shared tab menu helpers", () => {
|
||||
it("builds rename and duplicate items only when renaming is allowed", () => {
|
||||
const items = createRenameDuplicateTabItems({
|
||||
tab: { id: "t1" } as never,
|
||||
t,
|
||||
canRename: true,
|
||||
onRename: () => undefined,
|
||||
onDuplicate: () => undefined,
|
||||
});
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]?.label).toBe("contextMenu.renameTab");
|
||||
expect(items[1]?.label).toBe("contextMenu.duplicateTab");
|
||||
expect(items[1]?.visible).toBe(true);
|
||||
|
||||
const queryOnly = createRenameDuplicateTabItems({
|
||||
tab: { id: "t2" } as never,
|
||||
t,
|
||||
canRename: false,
|
||||
onRename: () => undefined,
|
||||
onDuplicate: () => undefined,
|
||||
});
|
||||
expect(queryOnly).toHaveLength(1);
|
||||
expect(queryOnly[0]?.visible).toBe(false);
|
||||
});
|
||||
|
||||
it("builds locate and pin menu items with the expected actions", () => {
|
||||
let located = false;
|
||||
let toggled = false;
|
||||
|
||||
const locate = createLocateTabMenuItem({
|
||||
t,
|
||||
visible: true,
|
||||
onLocate: () => {
|
||||
located = true;
|
||||
},
|
||||
});
|
||||
locate.action?.();
|
||||
expect(located).toBe(true);
|
||||
expect(locate.label).toBe("sidebar.locateActiveTab");
|
||||
|
||||
const hiddenLocate = createLocateTabMenuItem({
|
||||
t,
|
||||
visible: false,
|
||||
onLocate: () => undefined,
|
||||
});
|
||||
expect(hiddenLocate.visible).toBe(false);
|
||||
|
||||
const pin = createPinTabMenuItem({
|
||||
label: "Pin",
|
||||
iconClass: "fill-current",
|
||||
onToggle: () => {
|
||||
toggled = true;
|
||||
},
|
||||
});
|
||||
pin.action?.();
|
||||
expect(toggled).toBe(true);
|
||||
expect(pin.iconClass).toBe("fill-current");
|
||||
|
||||
const pinWithoutIconClass = createPinTabMenuItem({
|
||||
label: "Pin",
|
||||
onToggle: () => undefined,
|
||||
});
|
||||
expect(pinWithoutIconClass.iconClass).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds close menu items with labels and actions", () => {
|
||||
let closed = false;
|
||||
const close = createCloseTabMenuItem({
|
||||
label: "Close",
|
||||
onClose: () => {
|
||||
closed = true;
|
||||
},
|
||||
});
|
||||
close.action?.();
|
||||
expect(closed).toBe(true);
|
||||
|
||||
const closeOther = createCloseOtherTabMenuItem({
|
||||
label: "Close Other",
|
||||
disabled: true,
|
||||
onClose: () => undefined,
|
||||
});
|
||||
expect(closeOther.disabled).toBe(true);
|
||||
|
||||
const closeOtherWithoutDisabled = createCloseOtherTabMenuItem({
|
||||
label: "Close Other",
|
||||
onClose: () => undefined,
|
||||
});
|
||||
expect("disabled" in closeOtherWithoutDisabled).toBe(false);
|
||||
|
||||
const closeRight = createCloseRightTabMenuItem({
|
||||
label: "Close Right",
|
||||
disabled: false,
|
||||
onClose: () => undefined,
|
||||
});
|
||||
expect(closeRight.disabled).toBe(false);
|
||||
|
||||
const closeRightWithoutDisabled = createCloseRightTabMenuItem({
|
||||
label: "Close Right",
|
||||
onClose: () => undefined,
|
||||
});
|
||||
expect("disabled" in closeRightWithoutDisabled).toBe(false);
|
||||
|
||||
const closeLeft = createCloseLeftTabMenuItem({
|
||||
label: "Close Left",
|
||||
disabled: true,
|
||||
onClose: () => undefined,
|
||||
});
|
||||
expect(closeLeft.disabled).toBe(true);
|
||||
|
||||
const closeLeftWithoutDisabled = createCloseLeftTabMenuItem({
|
||||
label: "Close Left",
|
||||
onClose: () => undefined,
|
||||
});
|
||||
expect("disabled" in closeLeftWithoutDisabled).toBe(false);
|
||||
|
||||
const closeAll = createCloseAllTabMenuItem({
|
||||
label: "Close All",
|
||||
variant: "destructive",
|
||||
onClose: () => undefined,
|
||||
});
|
||||
expect(closeAll.variant).toBe("destructive");
|
||||
|
||||
const closeAllDefault = createCloseAllTabMenuItem({
|
||||
label: "Close All",
|
||||
onClose: () => undefined,
|
||||
});
|
||||
expect(closeAllDefault.variant).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -12,9 +12,13 @@ import {
|
||||
resultGridInstanceKey,
|
||||
resultSourceRange,
|
||||
statementExecutionMarkers,
|
||||
tabColorStyle,
|
||||
tabDatabaseIconType,
|
||||
tabDisplayTitle,
|
||||
tabIconClass,
|
||||
tabTooltipLines,
|
||||
tabularResultItems,
|
||||
dirtyTabTitleStyle,
|
||||
} from "@/lib/tabs/tabPresentation";
|
||||
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
|
||||
import type { ConnectionConfig, QueryTab } from "@/types/database";
|
||||
@@ -396,3 +400,38 @@ describe("statement execution markers", () => {
|
||||
expect(statementExecutionMarkers(`-- edited\n${executedSql}`, [{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, statement_index: 0, sourceStatement: "SELECT 1" }], "mysql", "stale-editor-fingerprint", executedSql)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared tab presentation helpers", () => {
|
||||
it("classifies tab icon colors without MQ special-casing", () => {
|
||||
expect(tabIconClass(queryTab({ mode: "data" }))).toContain("text-emerald-600");
|
||||
expect(tabIconClass(queryTab({ mode: "mq" }))).toBe("");
|
||||
expect(tabIconClass(queryTab({ externalSqlFileMissing: true }))).toContain("text-amber-600");
|
||||
});
|
||||
|
||||
it("builds active/inactive color styles for classic and non-classic layouts", () => {
|
||||
const activeClassic = tabColorStyle(queryTab({}), true, true);
|
||||
expect(activeClassic?.boxShadow).toContain("var(--ring)");
|
||||
const inactiveModern = tabColorStyle(queryTab({}), false, false);
|
||||
expect(inactiveModern?.borderColor).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves MQ driver icons from the connection store", () => {
|
||||
const connectionStore = useConnectionStore();
|
||||
connectionStore.connections = [
|
||||
{
|
||||
id: "mq-1",
|
||||
name: "MQ",
|
||||
db_type: "mq",
|
||||
driver_profile: "kafka",
|
||||
color: "",
|
||||
} as ConnectionConfig,
|
||||
];
|
||||
expect(tabDatabaseIconType(queryTab({ connectionId: "mq-1", mode: "mq" }))).toBe("kafka");
|
||||
});
|
||||
|
||||
it("returns a dirty-title style only when the tab is dirty", () => {
|
||||
expect(dirtyTabTitleStyle(false)).toBeUndefined();
|
||||
expect(dirtyTabTitleStyle(true)?.fontStyle).toBe("italic");
|
||||
expect(dirtyTabTitleStyle(true)?.fontWeight).toBe(700);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("AI Redis console routing", () => {
|
||||
const handler = appSource.match(/function onAiExecuteSql\(sql: string\) \{[\s\S]*?\n\}/)?.[0] ?? "";
|
||||
expect(handler).toContain("buildDeduplicatedAppendedEditorSql(currentSql, sql)");
|
||||
expect(handler).toContain("if (appendedSql !== currentSql) queryStore.updateSql(tabId, appendedSql);");
|
||||
expect(handler).toContain("runAiGeneratedSql(sql);");
|
||||
expect(handler).toContain("runAiGeneratedSql(sql, tabId);");
|
||||
expect(handler).not.toContain("buildAppendedEditorSql(");
|
||||
});
|
||||
|
||||
|
||||
@@ -66,6 +66,23 @@ export interface RestoredOpenTabs {
|
||||
activeTabId: string | null;
|
||||
}
|
||||
|
||||
/** Group membership persisted with the open-tabs payload. */
|
||||
export interface PersistedEditorGroup {
|
||||
id: string;
|
||||
tabIds: string[];
|
||||
activeTabId: string | null;
|
||||
}
|
||||
|
||||
/** Shared open-tabs transport payload — the single definition both backend adapters persist. */
|
||||
export interface OpenTabsStatePayload {
|
||||
tabs: unknown[];
|
||||
activeTabId: string | null;
|
||||
groups?: PersistedEditorGroup[];
|
||||
focusedGroupId?: string;
|
||||
orientation?: "vertical" | "horizontal";
|
||||
sizes?: number[];
|
||||
}
|
||||
|
||||
export type OpenTabsRestoreFilter = "all" | "pinned";
|
||||
export interface OpenTabsRestoreOptions {
|
||||
queryOnly?: boolean;
|
||||
|
||||
@@ -1870,10 +1870,8 @@ export async function saveMaxRetries(maxRetries: number): Promise<void> {
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
}
|
||||
|
||||
export interface OpenTabsStatePayload {
|
||||
tabs: unknown[];
|
||||
activeTabId: string | null;
|
||||
}
|
||||
export type { OpenTabsStatePayload, PersistedEditorGroup } from "@/lib/app/openTabsPersistence";
|
||||
import type { OpenTabsStatePayload } from "@/lib/app/openTabsPersistence";
|
||||
|
||||
export async function loadEditorSettings(): Promise<unknown | null> {
|
||||
return loadBrowserAppState("editor_settings");
|
||||
@@ -1891,6 +1889,10 @@ export async function loadOpenTabsState(): Promise<OpenTabsStatePayload | null>
|
||||
? {
|
||||
tabs: payload.tabs,
|
||||
activeTabId: typeof payload.activeTabId === "string" ? payload.activeTabId : null,
|
||||
...(payload.groups !== undefined ? { groups: payload.groups } : {}),
|
||||
...(typeof payload.focusedGroupId === "string" ? { focusedGroupId: payload.focusedGroupId } : {}),
|
||||
...(payload.orientation === "vertical" || payload.orientation === "horizontal" ? { orientation: payload.orientation } : {}),
|
||||
...(Array.isArray(payload.sizes) ? { sizes: payload.sizes } : {}),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -720,10 +720,8 @@ export async function saveMaxRetries(maxRetries: number): Promise<void> {
|
||||
return invoke("save_max_retries", { maxRetries });
|
||||
}
|
||||
|
||||
export interface OpenTabsStatePayload {
|
||||
tabs: unknown[];
|
||||
activeTabId: string | null;
|
||||
}
|
||||
export type { OpenTabsStatePayload, PersistedEditorGroup } from "@/lib/app/openTabsPersistence";
|
||||
import type { OpenTabsStatePayload } from "@/lib/app/openTabsPersistence";
|
||||
|
||||
export async function loadEditorSettings(): Promise<unknown | null> {
|
||||
return invoke("load_editor_settings");
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ContentAreaSurfaceEmits } from "@/components/layout/querySurfaces";
|
||||
|
||||
/**
|
||||
* Single source of truth for the events every content surface forwards
|
||||
* upward. Each layer (QueryEditorSurface / QueryResultSurface / EditorGroup /
|
||||
* SqlEditorWorkspace) derives its v-bind handler object from this list, so the
|
||||
* four forwarding layers cannot drift apart when the contract changes.
|
||||
*/
|
||||
export const contentSurfaceEventNames = [
|
||||
"update:activeOutputView",
|
||||
"fixWithAi",
|
||||
"sendSelectionToAi",
|
||||
"previewChangesAvailable",
|
||||
"execute",
|
||||
"executeInNewResultTab",
|
||||
"saveSql",
|
||||
"cancel",
|
||||
"explain",
|
||||
"editorUpdate",
|
||||
"editorSelectionChange",
|
||||
"editorCursorChange",
|
||||
"editorViewportChange",
|
||||
"editorSelectionStateChange",
|
||||
"formatError",
|
||||
"reload",
|
||||
"paginate",
|
||||
"sort",
|
||||
"executeSql",
|
||||
"clickTable",
|
||||
"viewTableData",
|
||||
"viewTableDdl",
|
||||
"editTableStructure",
|
||||
"openObjectSource",
|
||||
"openObjectTable",
|
||||
"objectSchemaChange",
|
||||
"objectBrowserViewportChange",
|
||||
"structureEditorSaved",
|
||||
"structureEditorClose",
|
||||
"previewStatement",
|
||||
"focusStatement",
|
||||
"openSettings",
|
||||
"openConnectionSettings",
|
||||
"toggleZenMode",
|
||||
] as const satisfies Array<keyof ContentAreaSurfaceEmits>;
|
||||
|
||||
export type ContentSurfaceEventName = (typeof contentSurfaceEventNames)[number];
|
||||
|
||||
export type ContentSurfaceEventForwarders = {
|
||||
[K in ContentSurfaceEventName as `on${Capitalize<K & string>}`]: (...args: ContentAreaSurfaceEmits[K]) => void;
|
||||
};
|
||||
|
||||
type ContentSurfaceEmit = <K extends ContentSurfaceEventName>(event: K, ...args: ContentAreaSurfaceEmits[K]) => void;
|
||||
|
||||
/**
|
||||
* Builds the v-bind event object that re-emits every shared content event.
|
||||
* Emitters stay typed through ContentAreaSurfaceEmits, so adding a new event
|
||||
* to the contract only requires extending the event list once.
|
||||
*/
|
||||
export function createContentSurfaceEventForwarders(emit: ContentSurfaceEmit): ContentSurfaceEventForwarders {
|
||||
const forwarders = {} as Record<string, (...args: unknown[]) => void>;
|
||||
for (const event of contentSurfaceEventNames) {
|
||||
const handlerName = `on${event.charAt(0).toUpperCase()}${event.slice(1)}`;
|
||||
forwarders[handlerName] = (...args: unknown[]) => {
|
||||
(emit as (event: ContentSurfaceEventName, ...args: unknown[]) => void)(event, ...args);
|
||||
};
|
||||
}
|
||||
return forwarders as ContentSurfaceEventForwarders;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Progressive condensation for the per-group SQL editor toolbar.
|
||||
*
|
||||
* The tier is NOT derived from fixed width breakpoints — the control set
|
||||
* changes per database type, transaction state, and feature flags, so any
|
||||
* static guess would over- or under-condense. Instead the toolbar measures its
|
||||
* own row: while the content overflows the available width it steps up one
|
||||
* tier (moving controls into the overflow menu); it only steps back down when
|
||||
* there is real slack AND the pane has grown meaningfully since the last
|
||||
* condensation, so a static narrow layout can never oscillate.
|
||||
*
|
||||
* - Tier 0: full layout.
|
||||
* - Tier 1: secondary edit/entry actions (compress, keyword case, semantic
|
||||
* diagnostics, open SQL, import archive, paste-in-condition, multi-execute)
|
||||
* move into the overflow menu.
|
||||
* - Tier 2: format and the database helper buttons (clear / set-default) join
|
||||
* the overflow menu.
|
||||
* - Tier 3: the explain-analyze toggle and the schema/catalog selectors leave
|
||||
* the row; the row keeps execute, explain, save, transaction controls, the
|
||||
* connection and database selectors (hard-truncated), and the overflow menu.
|
||||
*/
|
||||
export type EditorToolbarTier = 0 | 1 | 2 | 3;
|
||||
|
||||
export const EDITOR_TOOLBAR_MAX_TIER = 3;
|
||||
|
||||
/** Slack (px) required before giving a tier back — absorbs rendering jitter. */
|
||||
export const EDITOR_TOOLBAR_STEP_DOWN_SLACK_PX = 48;
|
||||
/** How much wider the pane must grow (px) vs the last condensation before stepping down. */
|
||||
export const EDITOR_TOOLBAR_STEP_DOWN_MIN_GROWTH_PX = 64;
|
||||
|
||||
export interface EditorToolbarTierInput {
|
||||
tier: EditorToolbarTier;
|
||||
/** Measured inner width of the toolbar row. */
|
||||
availableWidth: number;
|
||||
/** Measured extent of the row's content (scrollWidth), including overflow. */
|
||||
contentWidth: number;
|
||||
/** Toolbar width when the current tier was condensed into; anchors step-down hysteresis. */
|
||||
condensedAtWidth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the next toolbar tier from real measurements. Monotonic per
|
||||
* measurement — repeated calls after a tier change converge instead of
|
||||
* oscillating, because stepping down additionally requires the pane to have
|
||||
* grown by EDITOR_TOOLBAR_STEP_DOWN_MIN_GROWTH_PX since the last step up.
|
||||
*/
|
||||
export function resolveNextEditorToolbarTier(input: EditorToolbarTierInput): EditorToolbarTier {
|
||||
const { tier, availableWidth, contentWidth, condensedAtWidth = 0 } = input;
|
||||
if (!Number.isFinite(availableWidth) || !Number.isFinite(contentWidth) || availableWidth <= 0 || contentWidth <= 0) {
|
||||
// Unmeasured (first paint, hosts without ResizeObserver) keeps the current tier.
|
||||
return tier;
|
||||
}
|
||||
if (contentWidth > availableWidth && tier < EDITOR_TOOLBAR_MAX_TIER) {
|
||||
return (tier + 1) as EditorToolbarTier;
|
||||
}
|
||||
if (tier > 0 && availableWidth - contentWidth >= EDITOR_TOOLBAR_STEP_DOWN_SLACK_PX && availableWidth - condensedAtWidth >= EDITOR_TOOLBAR_STEP_DOWN_MIN_GROWTH_PX) {
|
||||
return (tier - 1) as EditorToolbarTier;
|
||||
}
|
||||
return tier;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export const TAB_DRAG_MIME = "application/x-dbx-tab";
|
||||
|
||||
export interface TabDragPayload {
|
||||
mime: typeof TAB_DRAG_MIME;
|
||||
tabId: string;
|
||||
sourceGroupId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the drag contract under the project's custom MIME type. The MIME
|
||||
* value travels inside the payload as a discriminator, so a drop handler can
|
||||
* reject foreign or stale payloads before touching store state.
|
||||
*/
|
||||
export function serializeTabDragPayload(payload: { tabId: string; sourceGroupId: string }): string {
|
||||
return JSON.stringify({ mime: TAB_DRAG_MIME, tabId: payload.tabId, sourceGroupId: payload.sourceGroupId });
|
||||
}
|
||||
|
||||
export function parseTabDragPayload(raw: string): TabDragPayload | null {
|
||||
try {
|
||||
const value = JSON.parse(raw) as Partial<TabDragPayload>;
|
||||
if (value.mime === TAB_DRAG_MIME && typeof value.tabId === "string" && typeof value.sourceGroupId === "string") {
|
||||
return { mime: TAB_DRAG_MIME, tabId: value.tabId, sourceGroupId: value.sourceGroupId };
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Copy, Crosshair, Pencil, Pin, X } from "@lucide/vue";
|
||||
import type { ContextMenuItem } from "@/components/ui/customContextMenuRegistry";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
|
||||
export function createRenameDuplicateTabItems(options: { tab: QueryTab; t: (key: string) => string; canRename: boolean; onRename: () => void; onDuplicate: () => void }): ContextMenuItem[] {
|
||||
return [
|
||||
...(options.canRename
|
||||
? [
|
||||
{
|
||||
label: options.t("contextMenu.renameTab"),
|
||||
action: options.onRename,
|
||||
icon: Pencil,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: options.t("contextMenu.duplicateTab"),
|
||||
action: options.onDuplicate,
|
||||
icon: Copy,
|
||||
visible: options.canRename,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function createLocateTabMenuItem(options: { t: (key: string) => string; visible: boolean; onLocate: () => void }): ContextMenuItem {
|
||||
return {
|
||||
label: options.t("sidebar.locateActiveTab"),
|
||||
action: options.onLocate,
|
||||
icon: Crosshair,
|
||||
visible: options.visible,
|
||||
};
|
||||
}
|
||||
|
||||
export function createPinTabMenuItem(options: { label: string; iconClass?: string; onToggle: () => void }): ContextMenuItem {
|
||||
return {
|
||||
label: options.label,
|
||||
action: options.onToggle,
|
||||
icon: Pin,
|
||||
...(options.iconClass ? { iconClass: options.iconClass } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createCloseTabMenuItem(options: { label: string; onClose: () => void }): ContextMenuItem {
|
||||
return { label: options.label, action: options.onClose, icon: X };
|
||||
}
|
||||
|
||||
export function createCloseOtherTabMenuItem(options: { label: string; disabled?: boolean; shortcut?: string; onClose: () => void }): ContextMenuItem {
|
||||
return {
|
||||
label: options.label,
|
||||
action: options.onClose,
|
||||
...(options.disabled !== undefined ? { disabled: options.disabled } : {}),
|
||||
...(options.shortcut ? { shortcut: options.shortcut } : {}),
|
||||
icon: X,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCloseRightTabMenuItem(options: { label: string; disabled?: boolean; onClose: () => void }): ContextMenuItem {
|
||||
return {
|
||||
label: options.label,
|
||||
action: options.onClose,
|
||||
...(options.disabled !== undefined ? { disabled: options.disabled } : {}),
|
||||
icon: X,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCloseLeftTabMenuItem(options: { label: string; disabled?: boolean; onClose: () => void }): ContextMenuItem {
|
||||
return {
|
||||
label: options.label,
|
||||
action: options.onClose,
|
||||
...(options.disabled !== undefined ? { disabled: options.disabled } : {}),
|
||||
icon: X,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCloseAllTabMenuItem(options: { label: string; variant?: "destructive"; onClose: () => void }): ContextMenuItem {
|
||||
return {
|
||||
label: options.label,
|
||||
action: options.onClose,
|
||||
...(options.variant ? { variant: options.variant } : {}),
|
||||
icon: X,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { hexToRgba } from "@/lib/common/color";
|
||||
import type { CSSProperties } from "vue";
|
||||
import { findConnectionGroupPath } from "@/lib/sidebar/sidebarLayout";
|
||||
import { splitMongoCommandRanges } from "@/lib/mongo/mongoShellCommand";
|
||||
import { executableStatementRanges, splitSqlStatementRanges, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
|
||||
@@ -464,3 +466,60 @@ export function tabModeLabel(tab: QueryTab, t: Translate): string {
|
||||
if (tab.mode === "dolt-version-control") return t("doltVersionControl.title");
|
||||
return tab.mode;
|
||||
}
|
||||
|
||||
export function tabDatabaseIconType(tab: QueryTab): string {
|
||||
const connectionStore = useConnectionStore();
|
||||
const connection = connectionStore.getConfig(tab.connectionId);
|
||||
if (!connection) return "mq";
|
||||
if (connection.db_type === "mq") {
|
||||
const externalConfig = connection.external_config as { systemKind?: unknown } | undefined;
|
||||
const systemKind = typeof externalConfig?.systemKind === "string" ? externalConfig.systemKind : "";
|
||||
if (connection.driver_profile === "kafka" || systemKind === "kafka") return "kafka";
|
||||
if (connection.driver_profile === "rocketmq" || systemKind === "rocketmq") return "rocketmq";
|
||||
if (connection.driver_profile === "rabbitmq" || systemKind === "rabbitmq") return "rabbitmq";
|
||||
if (connection.driver_profile === "pulsar" || systemKind === "pulsar") return "pulsar";
|
||||
}
|
||||
return connection.driver_profile || connection.db_type;
|
||||
}
|
||||
|
||||
export function tabIconClass(tab: QueryTab): string {
|
||||
if (tab.externalSqlFileMissing) return "text-amber-600 dark:text-amber-400";
|
||||
if (tab.mode === "mq") return "";
|
||||
if (tab.mode === "databases" || tab.mode === "objects") return "text-amber-500 dark:text-amber-400";
|
||||
if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "vector" || tab.mode === "redis" || tab.mode === "hbase" || tab.mode === "structure") return "text-emerald-600 dark:text-emerald-400";
|
||||
return "text-blue-600 dark:text-blue-400";
|
||||
}
|
||||
|
||||
export function tabColorStyle(tab: QueryTab, active: boolean, isClassic: boolean): CSSProperties | undefined {
|
||||
const color = connectionColor(tab.connectionId);
|
||||
if (!color) {
|
||||
if (isClassic) {
|
||||
return active ? { boxShadow: "inset 0 -2px 0 var(--ring)" } : undefined;
|
||||
}
|
||||
return active ? { borderColor: "var(--ring)" } : undefined;
|
||||
}
|
||||
if (isClassic) {
|
||||
return {
|
||||
"--app-tab-background": hexToRgba(color, active ? 0.16 : 0.07),
|
||||
"--app-tab-hover-background": hexToRgba(color, 0.14),
|
||||
boxShadow: active ? `inset 0 -2px 0 ${color}` : undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
"--app-tab-background": hexToRgba(color, active ? 0.16 : 0.09),
|
||||
"--app-tab-hover-background": hexToRgba(color, 0.16),
|
||||
borderColor: active ? hexToRgba(color, 0.72) : hexToRgba(color, 0.18),
|
||||
};
|
||||
}
|
||||
|
||||
export function dirtyTabTitleStyle(isDirty: boolean): CSSProperties | undefined {
|
||||
if (!isDirty) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
fontStyle: "italic",
|
||||
fontWeight: 700,
|
||||
transform: "skewX(-8deg)",
|
||||
transformOrigin: "left center",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.setConfig({ testTimeout: 10_000 });
|
||||
|
||||
// The detach chain must never touch the persistence transport: adopting a
|
||||
// handoff is an in-memory store transition; the file only moves via
|
||||
// saveDetachedTabHandoff in App-level code, which these tests do not drive.
|
||||
vi.mock("@/lib/backend/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/backend/api")>();
|
||||
return {
|
||||
...actual,
|
||||
loadOpenTabsState: vi.fn(() => Promise.resolve(null)),
|
||||
saveOpenTabsState: vi.fn(() => Promise.resolve()),
|
||||
saveDetachedTabHandoff: vi.fn(() => Promise.resolve()),
|
||||
deleteDetachedTabHandoff: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
});
|
||||
|
||||
describe("queryStore detached-tab handoff", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal("window", {
|
||||
dispatchEvent: vi.fn(),
|
||||
setTimeout: vi.fn((_fn: () => void) => 0),
|
||||
clearTimeout: vi.fn(),
|
||||
});
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
async function makeHandoff() {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = store.createTab("pg-1", "app", "Query 1", "query");
|
||||
// A result-less query tab keeps prepareDetachedTab off the backend cache.
|
||||
const handoff = await store.prepareDetachedTab(tabId);
|
||||
return { store, tabId, handoff };
|
||||
}
|
||||
|
||||
it("adopting a handoff lands the tab in the main pane so the split workspace can render it", async () => {
|
||||
const { handoff } = await makeHandoff();
|
||||
|
||||
// Fresh store = the detached window's store before adoption.
|
||||
setActivePinia(createPinia());
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const freshStore = useQueryStore();
|
||||
expect(freshStore.tabs).toHaveLength(0);
|
||||
|
||||
await freshStore.adoptDetachedTab(handoff);
|
||||
|
||||
expect(freshStore.tabs.map((tab) => tab.id)).toEqual([handoff.tabId]);
|
||||
// Without group membership the workspace renders the empty-group
|
||||
// placeholder regardless of tabs/activeTabId — membership is the fix.
|
||||
expect(freshStore.groups[0]?.tabIds).toEqual([handoff.tabId]);
|
||||
expect(freshStore.groups[0]?.activeTabId).toBe(handoff.tabId);
|
||||
expect(freshStore.activeTabId).toBe(handoff.tabId);
|
||||
expect(freshStore.focusedGroupId).toBe(freshStore.groups[0]?.id);
|
||||
});
|
||||
|
||||
it("removeTabAfterDetachedReady clears the owning pane and prunes it when emptied", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const mainTabId = store.createTab("pg-1", "app", "Query 1", "query");
|
||||
const detachedTabId = store.createTab("pg-1", "app", "Query 2", "query");
|
||||
store.splitTabRight(detachedTabId);
|
||||
expect(store.groups).toHaveLength(2);
|
||||
store.activateTab(detachedTabId);
|
||||
|
||||
expect(store.removeTabAfterDetachedReady(detachedTabId)).toBe(true);
|
||||
|
||||
// No dangling group entry: the emptied pane is pruned, the main pane and
|
||||
// its tab survive, and the global active tab re-syncs to a live tab.
|
||||
expect(store.tabs.map((tab) => tab.id)).toEqual([mainTabId]);
|
||||
expect(store.groups).toHaveLength(1);
|
||||
expect(store.groups[0]?.tabIds).toEqual([mainTabId]);
|
||||
expect(store.activeTabId).toBe(mainTabId);
|
||||
});
|
||||
|
||||
it("returning a detached tab re-homes it into the workspace after its pane was pruned", async () => {
|
||||
const { store, tabId, handoff } = await makeHandoff();
|
||||
store.activateTab(tabId);
|
||||
store.removeTabAfterDetachedReady(tabId);
|
||||
expect(store.tabs).toHaveLength(0);
|
||||
|
||||
await store.adoptDetachedTab(handoff);
|
||||
|
||||
expect(store.tabs.map((tab) => tab.id)).toEqual([tabId]);
|
||||
expect(store.groups[0]?.tabIds).toEqual([tabId]);
|
||||
expect(store.activeTabId).toBe(tabId);
|
||||
});
|
||||
|
||||
it("re-adopting the same handoff does not duplicate the tab or its group entry", async () => {
|
||||
const { handoff } = await makeHandoff();
|
||||
|
||||
setActivePinia(createPinia());
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const freshStore = useQueryStore();
|
||||
await freshStore.adoptDetachedTab(handoff);
|
||||
await freshStore.adoptDetachedTab(handoff);
|
||||
|
||||
expect(freshStore.tabs).toHaveLength(1);
|
||||
expect(freshStore.groups[0]?.tabIds).toEqual([handoff.tabId]);
|
||||
expect(freshStore.activeTabId).toBe(handoff.tabId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.setConfig({ testTimeout: 10_000 });
|
||||
|
||||
// Mock the persistence transport for the whole file: real timers scheduled by
|
||||
// one test's debounced persist must not leak a previous store's tabs into a
|
||||
// later test's localStorage-backed restore payload.
|
||||
const restoreWorkspacePayload = vi.hoisted(() => ({
|
||||
tabs: Array.from({ length: 5 }, (_, index) => ({ id: `tab-${index}`, title: `Query ${index + 1}`, connectionId: "pg-1", database: "app", sql: "select 1" })),
|
||||
activeTabId: "tab-0",
|
||||
groups: Array.from({ length: 5 }, (_, index) => ({ id: `g${index}`, tabIds: [`tab-${index}`], activeTabId: `tab-${index}` })),
|
||||
focusedGroupId: "g0",
|
||||
orientation: "vertical",
|
||||
sizes: [20, 20, 20, 20, 20],
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/backend/api")>();
|
||||
return {
|
||||
...actual,
|
||||
loadOpenTabsState: vi.fn(() => Promise.resolve(restoreWorkspacePayload)),
|
||||
saveOpenTabsState: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
});
|
||||
|
||||
describe("queryStore editor groups", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal("window", {
|
||||
dispatchEvent: vi.fn(),
|
||||
setTimeout: vi.fn((_fn: () => void) => 0),
|
||||
clearTimeout: vi.fn(),
|
||||
});
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("splits down and toggles orientation", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const firstId = store.createTab("pg-1", "app", "Query 1", "query");
|
||||
store.createTab("pg-1", "app", "Query 2", "query");
|
||||
|
||||
expect(store.splitTabDown(firstId)).toBe(true);
|
||||
expect(store.groups).toHaveLength(2);
|
||||
|
||||
store.setOrientation("horizontal");
|
||||
expect(store.orientation).toBe("horizontal");
|
||||
store.setOrientation("vertical");
|
||||
expect(store.orientation).toBe("vertical");
|
||||
});
|
||||
|
||||
it("moves a tab across groups and removes the empty source group", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
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");
|
||||
|
||||
store.splitTabRight(firstId);
|
||||
const rightGroup = store.groups[1];
|
||||
|
||||
expect(store.moveTabToGroup(secondId, rightGroup.id)).toBe(true);
|
||||
expect(rightGroup.tabIds).toContain(secondId);
|
||||
expect(store.groups[0].tabIds).toEqual([thirdId]);
|
||||
expect(store.groups).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("unsplits a secondary group tab back to the main group", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const firstId = store.createTab("pg-1", "app", "Query 1", "query");
|
||||
const secondId = store.createTab("pg-1", "app", "Query 2", "query");
|
||||
|
||||
store.splitTabRight(firstId);
|
||||
const mainId = store.groups[0].id;
|
||||
|
||||
expect(store.unsplitTab(firstId)).toBe(true);
|
||||
expect(store.groups).toHaveLength(1);
|
||||
expect(store.groups[0].id).toBe(mainId);
|
||||
expect(store.groups[0].tabIds).toEqual([secondId, firstId]);
|
||||
expect(store.focusedGroupId).toBe(mainId);
|
||||
});
|
||||
|
||||
it("activates a tab atomically across group, focus, and global active tab", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const firstId = store.createTab("pg-1", "app", "Query 1", "query");
|
||||
const thirdId = store.createTab("pg-1", "app", "Query 3", "query");
|
||||
|
||||
store.splitTabRight(firstId);
|
||||
const leftGroup = store.groups[0];
|
||||
const rightGroup = store.groups[1];
|
||||
|
||||
expect(store.activateTab(thirdId)).toBe(true);
|
||||
expect(leftGroup.activeTabId).toBe(thirdId);
|
||||
expect(store.focusedGroupId).toBe(leftGroup.id);
|
||||
expect(store.activeTabId).toBe(thirdId);
|
||||
|
||||
expect(store.activateTab(firstId)).toBe(true);
|
||||
expect(rightGroup.activeTabId).toBe(firstId);
|
||||
expect(store.focusedGroupId).toBe(rightGroup.id);
|
||||
expect(store.activeTabId).toBe(firstId);
|
||||
|
||||
expect(store.activateTab("missing")).toBe(false);
|
||||
});
|
||||
|
||||
it("close all in group only closes the same pinned partition as the trigger tab", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
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");
|
||||
|
||||
store.togglePinnedTab(secondId);
|
||||
const mainId = store.groups[0].id;
|
||||
|
||||
store.closeAllTabsInGroup(mainId, firstId);
|
||||
|
||||
expect(store.tabs.some((tab) => tab.id === firstId)).toBe(false);
|
||||
expect(store.tabs.some((tab) => tab.id === thirdId)).toBe(false);
|
||||
expect(store.tabs.some((tab) => tab.id === secondId)).toBe(true);
|
||||
|
||||
store.closeAllTabsInGroup(mainId, secondId);
|
||||
expect(store.tabs.some((tab) => tab.id === secondId)).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes more than four groups at the restore boundary without orphaning tabs", async () => {
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
await useSettingsStore().initEditorSettings();
|
||||
const store = useQueryStore();
|
||||
await store.initOpenTabs();
|
||||
|
||||
expect(store.groups.length).toBeLessThanOrEqual(4);
|
||||
const assigned = store.groups.flatMap((group) => group.tabIds);
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
expect(assigned.filter((candidate) => candidate === `tab-${index}`)).toHaveLength(1);
|
||||
}
|
||||
expect(store.activeTabId).toBe(store.groups[0].activeTabId);
|
||||
});
|
||||
|
||||
it("derives the restored global active tab from the focused group, not the stale payload value", async () => {
|
||||
// The payload is internally inconsistent: activeTabId points into g2 while
|
||||
// focusedGroupId is g1. Restore must trust the focused group.
|
||||
restoreWorkspacePayload.activeTabId = "tab-1";
|
||||
restoreWorkspacePayload.groups = [
|
||||
{ id: "g1", tabIds: ["tab-0"], activeTabId: "tab-0" },
|
||||
{ id: "g2", tabIds: ["tab-1"], activeTabId: "tab-1" },
|
||||
];
|
||||
restoreWorkspacePayload.focusedGroupId = "g1";
|
||||
restoreWorkspacePayload.sizes = [50, 50];
|
||||
|
||||
const { useSettingsStore } = await import("@/stores/settingsStore");
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
await useSettingsStore().initEditorSettings();
|
||||
const store = useQueryStore();
|
||||
await store.initOpenTabs();
|
||||
|
||||
expect(store.focusedGroupId).toBe("g1");
|
||||
expect(store.activeTabId).toBe("tab-0");
|
||||
expect(store.groups[1].activeTabId).toBe("tab-1");
|
||||
|
||||
restoreWorkspacePayload.activeTabId = "tab-0";
|
||||
restoreWorkspacePayload.groups = Array.from({ length: 5 }, (_, index) => ({ id: `g${index}`, tabIds: [`tab-${index}`], activeTabId: `tab-${index}` }));
|
||||
restoreWorkspacePayload.focusedGroupId = "g0";
|
||||
restoreWorkspacePayload.sizes = [20, 20, 20, 20, 20];
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.setConfig({ testTimeout: 10_000 });
|
||||
|
||||
describe("queryStore split right", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal("window", { dispatchEvent: vi.fn() });
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("moves a query tab into a new right group and focuses it", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const queryStore = useQueryStore();
|
||||
const firstId = queryStore.createTab("pg-1", "app", "Query 1", "query");
|
||||
const secondId = queryStore.createTab("pg-1", "app", "Query 2", "query");
|
||||
|
||||
const ok = queryStore.splitTabRight(firstId);
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(queryStore.groups).toHaveLength(2);
|
||||
expect(queryStore.groups[0].tabIds).toEqual([secondId]);
|
||||
expect(queryStore.groups[1].tabIds).toEqual([firstId]);
|
||||
expect(queryStore.focusedGroupId).toBe(queryStore.groups[1].id);
|
||||
expect(queryStore.activeTabId).toBe(firstId);
|
||||
});
|
||||
|
||||
it("allows up to four groups and rejects the fifth split", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const queryStore = useQueryStore();
|
||||
const ids = [
|
||||
queryStore.createTab("pg-1", "app", "Query 1", "query"),
|
||||
queryStore.createTab("pg-1", "app", "Query 2", "query"),
|
||||
queryStore.createTab("pg-1", "app", "Query 3", "query"),
|
||||
queryStore.createTab("pg-1", "app", "Query 4", "query"),
|
||||
queryStore.createTab("pg-1", "app", "Query 5", "query"),
|
||||
];
|
||||
|
||||
expect(queryStore.splitTabRight(ids[1])).toBe(true);
|
||||
expect(queryStore.groups).toHaveLength(2);
|
||||
expect(queryStore.splitTabRight(ids[2])).toBe(true);
|
||||
expect(queryStore.groups).toHaveLength(3);
|
||||
expect(queryStore.splitTabRight(ids[3])).toBe(true);
|
||||
expect(queryStore.groups).toHaveLength(4);
|
||||
expect(queryStore.splitTabRight(ids[4])).toBe(false);
|
||||
expect(queryStore.groups).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("splits non-query tabs into their own group like query tabs", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const queryStore = useQueryStore();
|
||||
// A companion query tab keeps the store past the single-tab guard.
|
||||
queryStore.createTab("pg-1", "app", "Query 1", "query");
|
||||
const dataId = queryStore.createTab("pg-1", "app", "users", "data", "public");
|
||||
|
||||
expect(queryStore.splitTabRight(dataId)).toBe(true);
|
||||
expect(queryStore.groups).toHaveLength(2);
|
||||
expect(queryStore.groups[1].tabIds).toEqual([dataId]);
|
||||
expect(queryStore.groups[1].activeTabId).toBe(dataId);
|
||||
expect(queryStore.activeTabId).toBe(dataId);
|
||||
});
|
||||
|
||||
it("rejects splitting the only open tab, which would be a no-op", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const queryStore = useQueryStore();
|
||||
const onlyId = queryStore.createTab("pg-1", "app", "Query 1", "query");
|
||||
const groupIdBefore = queryStore.groups[0].id;
|
||||
|
||||
const ok = queryStore.splitTabRight(onlyId);
|
||||
|
||||
// Splitting would move the tab into a new group and prune the emptied
|
||||
// source group, returning to the same single-group layout — rejected so
|
||||
// the menu item can render disabled instead of doing nothing.
|
||||
expect(ok).toBe(false);
|
||||
expect(queryStore.groups).toHaveLength(1);
|
||||
expect(queryStore.groups[0].id).toBe(groupIdBefore);
|
||||
expect(queryStore.groups[0].tabIds).toEqual([onlyId]);
|
||||
expect(queryStore.focusedGroupId).toBe(groupIdBefore);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -28,5 +28,7 @@ test("data tab header shows the active connection before the table context", ()
|
||||
assert.match(template, /v-if="activeConnection\?\.name\?\.trim\(\)"/);
|
||||
assert.match(template, /:title="activeConnection\.name"/);
|
||||
assert.doesNotMatch(template, /data-data-header-connection class="[^"]*(?:max-w-4[89]|max-w-5[0-9])/);
|
||||
assert.match(template, /data-data-header-connection class="[^"]*min-w-0[^"]*truncate/);
|
||||
// min-w floor keeps scrollWidth reporting real overflow for the measured
|
||||
// condensation tiers; chips still flex and only truncate under pressure.
|
||||
assert.match(template, /data-data-header-connection class="[^"]*min-w-12[^"]*truncate/);
|
||||
});
|
||||
|
||||
+1
-1
@@ -77,7 +77,7 @@ test("ContentArea wires server messages into the switcher and the messages view"
|
||||
assert.match(contentArea, /<QueryMessagesView v-else-if="activeOutputView === 'messages'"[\s\S]*:messages="activeTab\.result\?\.messages \?\? \[\]"/);
|
||||
// Results with no result set auto-switch via the shared default-view helper.
|
||||
assert.match(contentArea, /import \{ defaultViewForResult \} from "@\/lib\/query\/queryResultDefaultView"/);
|
||||
assert.match(contentArea, /emit\("update:activeOutputView", result \? defaultViewForResult\(result\) : "summary"\)/);
|
||||
assert.match(contentArea, /emit\("update:activeOutputView", props\.activeTab\.id, result \? defaultViewForResult\(result\) : "summary"\)/);
|
||||
});
|
||||
|
||||
test("every locale defines the query message strings", () => {
|
||||
|
||||
+1
-1
@@ -230,5 +230,5 @@ test("DataGrid marks toolbar refresh separately from current-result reloads", ()
|
||||
test("Elasticsearch JSON refresh preserves multi-result query groups", () => {
|
||||
const contentArea = source(contentAreaPath);
|
||||
|
||||
assert.match(contentArea, /if \(activeElasticsearchJsonResponse\.value\) \{[\s\S]*?emit\("reload", activeResultSql\.value, undefined, undefined, undefined, undefined, undefined, "refresh"\);/);
|
||||
assert.match(contentArea, /if \(activeElasticsearchJsonResponse\.value\) \{[\s\S]*?emit\("reload", props\.activeTab\.id, activeResultSql\.value, undefined, undefined, undefined, undefined, undefined, "refresh"\);/);
|
||||
});
|
||||
|
||||
+7
-7
@@ -96,7 +96,7 @@ test("data reload executes before slow metadata refresh completes", async () =>
|
||||
assert.ok(tab);
|
||||
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
const reload = actions.onReloadData(undefined, undefined, undefined, undefined, 50, 0);
|
||||
const reload = actions.onReloadData(tabId, undefined, undefined, undefined, undefined, 50, 0);
|
||||
|
||||
await waitFor(() => !!executeBody, 5_000);
|
||||
assert.equal(executeBody.clientSessionId, tabId);
|
||||
@@ -161,7 +161,7 @@ test("data reload preserves current page offset instead of resetting to page 1",
|
||||
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
// Simulate refresh from page 5 with pageSize=100 → offset=400
|
||||
await actions.onReloadData(undefined, undefined, undefined, undefined, 100, 400);
|
||||
await actions.onReloadData(tabId, undefined, undefined, undefined, undefined, 100, 400);
|
||||
|
||||
assert.equal(buildSqlOptions?.offset, 400, "build-table-select-sql must receive offset=400 to stay on page 5");
|
||||
assert.equal(buildSqlOptions?.limit, 100, "limit must be 100");
|
||||
@@ -210,7 +210,7 @@ test("infinite pagination fetches and appends only the next table segment", asyn
|
||||
tab.resultPageOffset = 0;
|
||||
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
await actions.onPaginate(1, 100);
|
||||
await actions.onPaginate(tabId, 1, 100);
|
||||
|
||||
assert.equal(buildSqlOptions?.offset, 1);
|
||||
assert.equal(buildSqlOptions?.limit, 100);
|
||||
@@ -264,8 +264,8 @@ test("query toolbar refresh reruns the complete multi-result SQL and keeps the a
|
||||
});
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
await actions.onReloadData("select * from orders", undefined, undefined, undefined, 100, 0, "refresh");
|
||||
await actions.onReloadData("select * from orders", undefined, undefined, undefined, 100, 0, "refresh");
|
||||
await actions.onReloadData(tabId, "select * from orders", undefined, undefined, undefined, 100, 0, "refresh");
|
||||
await actions.onReloadData(tabId, "select * from orders", undefined, undefined, undefined, 100, 0, "refresh");
|
||||
|
||||
assert.equal(executeTabSql.mock.calls.length, 2);
|
||||
for (const call of executeTabSql.mock.calls) {
|
||||
@@ -389,7 +389,7 @@ describe("multi-result preservation after submit", () => {
|
||||
|
||||
// Simulate DataGrid submit reload: onReloadData with the current
|
||||
// result's sourceStatement (single statement, not the full batch SQL)
|
||||
await actions.onReloadData("select * from users");
|
||||
await actions.onReloadData(tabId, "select * from users");
|
||||
|
||||
// The second result must be preserved
|
||||
assert.equal(tab.results?.length, 2, "both results must survive after submit");
|
||||
@@ -493,7 +493,7 @@ describe("multi-result preservation after submit", () => {
|
||||
const actions = useDataGridActions(computed(() => tab));
|
||||
|
||||
// Simulate DataGrid submit reload on the SECOND result
|
||||
await actions.onReloadData("select * from orders");
|
||||
await actions.onReloadData(tabId, "select * from orders");
|
||||
|
||||
// The first result must be preserved
|
||||
assert.equal(tab.results?.length, 2, "both results must survive after submit on second result");
|
||||
|
||||
Reference in New Issue
Block a user