From 1a6e00acbbbfb479ef5af38336aad230125d9ff0 Mon Sep 17 00:00:00 2001 From: zipg Date: Wed, 29 Jul 2026 18:42:01 +0800 Subject: [PATCH] fix(grid): improve filter condition expansion --- apps/desktop/src/App.vue | 6 +- .../grid/DataGridConditionEditor.vue | 83 ++++++++++++++++--- .../__tests__/DataGridConditionEditor.spec.ts | 42 ++++++++++ .../src/lib/app/openTabsPersistence.ts | 16 +++- .../src/lib/dataGrid/dataGridToolbar.ts | 2 +- apps/desktop/src/stores/queryStore.ts | 12 +-- .../app-tests/openTabsPersistence.test.ts | 43 ++++++++++ 7 files changed, 180 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index a01e7d641..94d5dc507 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -1976,7 +1976,9 @@ async function initApp() { try { await settingsStore.initEditorSettings(); console.log(`[STARTUP] settingsStore.initEditorSettings: ${(performance.now() - t0).toFixed(0)}ms`); - await queryStore.initOpenTabs(); + await connectionStore.initFromDisk(); + console.log(`[STARTUP] connectionStore.initFromDisk: ${(performance.now() - t0).toFixed(0)}ms`); + await queryStore.initOpenTabs({ validConnectionIds: connectionStore.connections.map((connection) => connection.id) }); console.log(`[STARTUP] queryStore.initOpenTabs: ${(performance.now() - t0).toFixed(0)}ms`); await settingsStore.initDesktopSettings().catch(() => {}); @@ -1991,8 +1993,6 @@ async function initApp() { toast(t("connection.loadFailed", { message: e?.message || String(e) }), 5000); }); - await connectionStore.initFromDisk(); - console.log(`[STARTUP] connectionStore.initFromDisk: ${(performance.now() - t0).toFixed(0)}ms`); restoreActiveConnectionContext(); } catch (e: any) { toast(t("connection.loadFailed", { message: e?.message || String(e) }), 5000); diff --git a/apps/desktop/src/components/grid/DataGridConditionEditor.vue b/apps/desktop/src/components/grid/DataGridConditionEditor.vue index e81bbfa4d..0bb3137a7 100644 --- a/apps/desktop/src/components/grid/DataGridConditionEditor.vue +++ b/apps/desktop/src/components/grid/DataGridConditionEditor.vue @@ -98,7 +98,7 @@ function createTextProbe(input: HTMLTextAreaElement, wrap: boolean) { const probe = document.createElement(wrap ? "div" : "span"); const style = window.getComputedStyle(input); probe.textContent = input.value || input.placeholder || ""; - probe.style.cssText = `position:fixed;left:-9999px;top:-9999px;visibility:hidden;box-sizing:border-box;${wrap ? `width:${input.clientWidth}px;white-space:pre-wrap;overflow-wrap:anywhere;padding:${style.paddingTop} ${style.paddingRight} ${style.paddingBottom} ${style.paddingLeft};` : "white-space:pre;"}font:${style.font};font-size:${style.fontSize};font-family:${style.fontFamily};font-weight:${style.fontWeight};line-height:${style.lineHeight};letter-spacing:${style.letterSpacing};`; + probe.style.cssText = `position:fixed;left:-9999px;top:-9999px;visibility:hidden;box-sizing:border-box;${wrap ? `width:${input.clientWidth}px;white-space:pre-wrap;overflow-wrap:normal;padding:${style.paddingTop} ${style.paddingRight} ${style.paddingBottom} ${style.paddingLeft};` : "white-space:pre;"}font:${style.font};font-size:${style.fontSize};font-family:${style.fontFamily};font-weight:${style.fontWeight};line-height:${style.lineHeight};letter-spacing:${style.letterSpacing};`; document.body.appendChild(probe); return probe; } @@ -142,7 +142,15 @@ function updateSuggestionPosition() { void nextTick(() => { const target = activeEditor.value; if (!target) return; - suggestionPosition.value = getDataGridConditionSuggestionPosition(target.getBoundingClientRect(), { + const targetRect = target.getBoundingClientRect(); + const suggestionAnchor = expanded.value + ? { + left: targetRect.left, + bottom: expandedRect.value.top + expandedHeight.value, + width: targetRect.width, + } + : targetRect; + suggestionPosition.value = getDataGridConditionSuggestionPosition(suggestionAnchor, { viewportWidth: window.innerWidth, preferredWidth: suggestionPreferredWidth.value, maxWidth: suggestionPreferredWidth.value === undefined ? undefined : 520, @@ -160,7 +168,8 @@ function resizeEditor(forceExpand = false) { expandAfterComposition = true; return; } - const focused = document.activeElement === input || document.activeElement === overlayRef.value; + const overlayFocused = document.activeElement === overlayRef.value; + const focused = document.activeElement === input || overlayFocused; const nextExpanded = focused && shouldExpand(input) && (forceExpand || expanded.value); if (nextExpanded) { expandedRect.value = measureExpandedRect(input); @@ -177,6 +186,12 @@ function resizeEditor(forceExpand = false) { overlay.setSelectionRange(selectionStart.value, selectionEnd.value); }); } + if (!nextExpanded && overlayFocused && !composing.value) { + void nextTick(() => { + input.focus(); + input.setSelectionRange(selectionStart.value, selectionEnd.value); + }); + } }); } @@ -203,6 +218,36 @@ function focus(select = false) { resizeEditor(true); } +function scrollCaretIntoView() { + const target = activeEditor.value; + if (!target || target.scrollHeight <= target.clientHeight) return; + const style = window.getComputedStyle(target); + const probe = document.createElement("div"); + const caretMarker = document.createElement("span"); + const caret = Math.min(Math.max(selectionStart.value, 0), target.value.length); + probe.textContent = target.value.slice(0, caret) || " "; + caretMarker.textContent = "\u200b"; + probe.appendChild(caretMarker); + probe.style.cssText = `position:fixed;left:-9999px;top:-9999px;visibility:hidden;box-sizing:border-box;width:${target.clientWidth}px;white-space:${style.whiteSpace};overflow-wrap:${style.overflowWrap};padding:${style.paddingTop} ${style.paddingRight} ${style.paddingBottom} ${style.paddingLeft};font:${style.font};font-size:${style.fontSize};font-family:${style.fontFamily};font-weight:${style.fontWeight};line-height:${style.lineHeight};letter-spacing:${style.letterSpacing};text-indent:${style.textIndent};`; + document.body.appendChild(probe); + const lineHeight = Number.parseFloat(style.lineHeight) || 24; + const caretTop = caretMarker.offsetTop; + const topPadding = Number.parseFloat(style.paddingTop) || 0; + const bottomPadding = Number.parseFloat(style.paddingBottom) || 0; + const visibleTop = target.scrollTop + topPadding; + const visibleBottom = target.scrollTop + target.clientHeight - bottomPadding; + if (caretTop < visibleTop) target.scrollTop = Math.max(0, caretTop - topPadding); + else if (caretTop + lineHeight > visibleBottom) target.scrollTop = caretTop + lineHeight + bottomPadding - target.clientHeight; + probe.remove(); +} + +function focusAfterAccept() { + void nextTick(() => { + focus(); + void nextTick(scrollCaretIntoView); + }); +} + function syncSelection(target: HTMLTextAreaElement) { selectionStart.value = target.selectionStart; selectionEnd.value = target.selectionEnd; @@ -255,7 +300,7 @@ function onKeydown(event: KeyboardEvent) { if (completeQuote(event)) return; const action = editor.handleKeydown(event); if (action === "apply") void applyCondition(); - if (action === "accept") void nextTick(() => focus()); + if (action === "accept") focusAfterAccept(); } function completeQuote(event: KeyboardEvent) { @@ -283,7 +328,7 @@ function openHistory() { function acceptSuggestion(index: number) { editor.accept(index); - void nextTick(() => focus()); + focusAfterAccept(); } function eventInside(event: Event, element?: HTMLElement) { @@ -433,8 +478,8 @@ defineExpose({ focus, dismiss: editor.dismiss, rememberHistory: editor.rememberH @input="onInput" @keydown="onKeydown" /> -
- +
+ {{ props.kind === "where" ? "WHERE" : "ORDER BY" }}
@@ -515,6 +560,16 @@ defineExpose({ focus, dismiss: editor.dismiss, rememberHistory: editor.rememberH color: rgb(234 88 12); } +.data-grid-topbar-condition-label--floating { + position: relative; + z-index: 1; + text-shadow: + -1px 0 color-mix(in oklab, var(--background) 96%, var(--muted) 4%), + 1px 0 color-mix(in oklab, var(--background) 96%, var(--muted) 4%), + 0 -1px color-mix(in oklab, var(--background) 96%, var(--muted) 4%), + 0 1px color-mix(in oklab, var(--background) 96%, var(--muted) 4%); +} + :global(.dark) .data-grid-topbar-condition-label--where { color: rgb(96 165 250); } @@ -523,6 +578,14 @@ defineExpose({ focus, dismiss: editor.dismiss, rememberHistory: editor.rememberH color: rgb(251 146 60); } +:global(.dark) .data-grid-topbar-condition-label--floating { + text-shadow: + -1px 0 rgb(24, 24, 27), + 1px 0 rgb(24, 24, 27), + 0 -1px rgb(24, 24, 27), + 0 1px rgb(24, 24, 27); +} + .data-grid-topbar-condition-label--compact { max-width: 0; opacity: 0; @@ -586,9 +649,7 @@ defineExpose({ focus, dismiss: editor.dismiss, rememberHistory: editor.rememberH box-shadow: inset 0 -1px 0 var(--border), 0 8px 16px rgb(15 23 42 / 8%); - transition: - height 150ms ease, - box-shadow 150ms ease; + transition: box-shadow 150ms ease; --data-grid-expanded-scrollbar-offset: 8px; --data-grid-condition-controls-top: 0.125rem; --data-grid-condition-input-top: 0.125rem; @@ -620,7 +681,7 @@ defineExpose({ focus, dismiss: editor.dismiss, rememberHistory: editor.rememberH overflow-x: hidden; overflow-y: auto; white-space: pre-wrap; - overflow-wrap: anywhere; + overflow-wrap: normal; border: 0; border-radius: 0; background: transparent; diff --git a/apps/desktop/src/components/grid/__tests__/DataGridConditionEditor.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridConditionEditor.spec.ts index ac6edccb2..1d11fe095 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridConditionEditor.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridConditionEditor.spec.ts @@ -1,6 +1,8 @@ // @vitest-environment happy-dom import { createApp, defineComponent, h, nextTick, ref, type App } from "vue"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import DataGridConditionEditor from "@/components/grid/DataGridConditionEditor.vue"; import type { DataGridConditionHistoryKind } from "@/lib/dataGrid/dataGridConditionHistory"; @@ -99,4 +101,44 @@ describe("DataGridConditionEditor quote completion", () => { expect(input.selectionStart).toBe(20); expect(input.selectionEnd).toBe(20); }); + + it("keeps expanded input first-line indent without forced word breaks", () => { + const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8"); + const expandedInputCss = source.match(/\.data-grid-topbar-condition-input--expanded\s*\{(?[\s\S]*?)\n\}/)?.groups?.body; + + expect(expandedInputCss).toContain("padding:"); + expect(expandedInputCss).toContain("text-indent: var(--data-grid-condition-prefix-indent)"); + expect(expandedInputCss).toContain("overflow-wrap: normal"); + expect(source).toContain("white-space:pre-wrap;overflow-wrap:normal;"); + }); + + it("keeps the expanded condition label readable over wrapped content", () => { + const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8"); + const floatingControls = source.match(/data-grid-topbar-condition-floating-controls[^"]*/)?.[0]; + const floatingLabelCss = source.match(/\.data-grid-topbar-condition-label--floating\s*\{(?[\s\S]*?)\n\}/)?.groups?.body; + + expect(floatingControls).toContain("z-[2]"); + expect(source).toContain("data-grid-topbar-condition-label--floating"); + expect(floatingLabelCss).toContain("text-shadow:"); + expect(floatingLabelCss).not.toContain("padding-right:"); + expect(floatingLabelCss).not.toContain("box-shadow:"); + }); + + it("scrolls the caret into view after accepting a long completion", () => { + const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8"); + + expect(source).toContain("function scrollCaretIntoView()"); + expect(source).toContain("function focusAfterAccept()"); + expect(source).toContain("void nextTick(scrollCaretIntoView)"); + expect(source).toContain('if (action === "accept") focusAfterAccept()'); + }); + + it("positions suggestions below the measured expanded editor height", () => { + const source = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/grid/DataGridConditionEditor.vue"), "utf8"); + const expandedPaneCss = source.match(/\.data-grid-topbar-condition-pane--expanded\s*\{(?[\s\S]*?)\n\}/)?.groups?.body; + + expect(source).toContain("bottom: expandedRect.value.top + expandedHeight.value"); + expect(expandedPaneCss).toContain("transition: box-shadow 150ms ease"); + expect(expandedPaneCss).not.toContain("height 150ms"); + }); }); diff --git a/apps/desktop/src/lib/app/openTabsPersistence.ts b/apps/desktop/src/lib/app/openTabsPersistence.ts index f9d3bc898..6e697df4e 100644 --- a/apps/desktop/src/lib/app/openTabsPersistence.ts +++ b/apps/desktop/src/lib/app/openTabsPersistence.ts @@ -61,6 +61,11 @@ export interface RestoredOpenTabs { } export type OpenTabsRestoreFilter = "all" | "pinned"; +export interface OpenTabsRestoreOptions { + queryOnly?: boolean; + filter?: OpenTabsRestoreFilter; + validConnectionIds?: Iterable; +} function shouldPersistTabSql(tab: QueryTab) { if (!tab.savedSqlId) return true; @@ -143,14 +148,17 @@ function isSavedOpenTab(value: unknown): value is SavedOpenTab { return typeof tab.id === "string" && typeof tab.title === "string" && typeof tab.connectionId === "string" && typeof tab.database === "string" && (typeof tab.sql === "string" || typeof tab.savedSqlId === "string"); } -function restoreOpenTabsArray(parsed: unknown, rawActiveTabId: string | null, options: { queryOnly?: boolean; filter?: OpenTabsRestoreFilter } = {}): RestoredOpenTabs { +function restoreOpenTabsArray(parsed: unknown, rawActiveTabId: string | null, options: OpenTabsRestoreOptions = {}): RestoredOpenTabs { if (!Array.isArray(parsed)) return { tabs: [], activeTabId: null }; try { + const validConnectionIds = options.validConnectionIds ? new Set(options.validConnectionIds) : undefined; const saved = parsed.filter(isSavedOpenTab); const filtered = saved.filter((tab) => { - if (options.queryOnly && (tab.mode ?? "query") !== "query") return false; + const mode = tab.mode ?? "query"; + if (options.queryOnly && mode !== "query") return false; if (options.filter === "pinned" && !tab.pinned) return false; + if (mode !== "query" && validConnectionIds && !validConnectionIds.has(tab.connectionId)) return false; return true; }); const tabs: QueryTab[] = filtered.map((tab) => { @@ -194,12 +202,12 @@ function restoreOpenTabsArray(parsed: unknown, rawActiveTabId: string | null, op } } -export function restoreOpenTabsPayload(payload: { tabs?: unknown; activeTabId?: unknown } | null | undefined, options: { queryOnly?: boolean; filter?: OpenTabsRestoreFilter } = {}): RestoredOpenTabs { +export function restoreOpenTabsPayload(payload: { tabs?: unknown; activeTabId?: unknown } | null | undefined, options: OpenTabsRestoreOptions = {}): RestoredOpenTabs { if (!payload) return { tabs: [], activeTabId: null }; return restoreOpenTabsArray(payload.tabs, typeof payload.activeTabId === "string" ? payload.activeTabId : null, options); } -export function restoreOpenTabsState(rawTabs: string | null, rawActiveTabId: string | null, options: { queryOnly?: boolean; filter?: OpenTabsRestoreFilter } = {}): RestoredOpenTabs { +export function restoreOpenTabsState(rawTabs: string | null, rawActiveTabId: string | null, options: OpenTabsRestoreOptions = {}): RestoredOpenTabs { if (!rawTabs) return { tabs: [], activeTabId: null }; try { diff --git a/apps/desktop/src/lib/dataGrid/dataGridToolbar.ts b/apps/desktop/src/lib/dataGrid/dataGridToolbar.ts index d690e1ffb..192cde8d8 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridToolbar.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridToolbar.ts @@ -1,4 +1,4 @@ -export const DATA_GRID_COMPACT_TOPBAR_WIDTH = 900; +export const DATA_GRID_COMPACT_TOPBAR_WIDTH = 1050; export type DataGridReloadIntent = "refresh"; diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 34c43e83e..d51d47bbb 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -448,20 +448,22 @@ function clearLegacySavedTabs() { safeLocalStorageRemove(ACTIVE_TAB_STORAGE_KEY); } -function restoreSavedTabsFromPayload(payload: { tabs?: unknown; activeTabId?: unknown } | null | undefined): { tabs: QueryTab[]; activeTabId: string | null } { +function restoreSavedTabsFromPayload(payload: { tabs?: unknown; activeTabId?: unknown } | null | undefined, options: { validConnectionIds?: Iterable } = {}): { tabs: QueryTab[]; activeTabId: string | null } { const restoreMode = useSettingsStore().editorSettings.openTabsRestoreMode; if (restoreMode === "none") return { tabs: [], activeTabId: null }; return restoreOpenTabsPayload(payload, { filter: restoreMode === "pinned" ? "pinned" : "all", + validConnectionIds: options.validConnectionIds, }); } -function restoreLegacySavedTabs(): { tabs: QueryTab[]; activeTabId: string | null } { +function restoreLegacySavedTabs(options: { validConnectionIds?: Iterable } = {}): { tabs: QueryTab[]; activeTabId: string | null } { const restoreMode = useSettingsStore().editorSettings.openTabsRestoreMode; if (restoreMode === "none") return { tabs: [], activeTabId: null }; const legacy = loadLegacySavedTabs(); return restoreOpenTabsState(legacy.rawTabs, legacy.rawActiveTabId, { filter: restoreMode === "pinned" ? "pinned" : "all", + validConnectionIds: options.validConnectionIds, }); } @@ -1043,11 +1045,11 @@ export const useQueryStore = defineStore("query", () => { else setTimeout(maintain, 0); } - async function initOpenTabs() { + async function initOpenTabs(options: { validConnectionIds?: Iterable } = {}) { if (isOpenTabsLoaded.value) return; const saved = await api.loadOpenTabsState().catch(() => null); if (saved?.tabs && Array.isArray(saved.tabs)) { - const restored = restoreSavedTabsFromPayload(saved); + const restored = restoreSavedTabsFromPayload(saved, options); applyRestoredOpenTabs(restored); if (useSettingsStore().editorSettings.openTabsRestoreMode === "none") { // Restore is explicitly disabled, so stale saved payloads should not @@ -1062,7 +1064,7 @@ export const useQueryStore = defineStore("query", () => { const legacy = loadLegacySavedTabs(); if (legacy.rawTabs || legacy.rawActiveTabId) { - const restored = restoreLegacySavedTabs(); + const restored = restoreLegacySavedTabs(options); applyRestoredOpenTabs(restored); if (useSettingsStore().editorSettings.openTabsRestoreMode === "none") { // Restore is explicitly disabled, so keeping the legacy startup payload diff --git a/packages/app-tests/openTabsPersistence.test.ts b/packages/app-tests/openTabsPersistence.test.ts index aea8ce76c..5e5d29135 100644 --- a/packages/app-tests/openTabsPersistence.test.ts +++ b/packages/app-tests/openTabsPersistence.test.ts @@ -342,6 +342,49 @@ test("restores data and structure tabs with table state", () => { assert.equal(restored.tabs[1]?.structureTableName, "users"); }); +test("drops restored non-query tabs for missing connections but keeps query drafts", () => { + const raw = serializeOpenTabs([ + queryTab({ + id: "query", + title: "Draft", + connectionId: "missing-conn", + database: "app", + sql: "select * from users", + }), + queryTab({ + id: "data", + title: "users", + connectionId: "missing-conn", + database: "app", + mode: "data", + sql: "", + tableMeta: { + schema: "public", + tableName: "users", + columns: [], + primaryKeys: [], + }, + }), + queryTab({ + id: "structure", + title: "Edit users", + connectionId: "missing-conn", + database: "app", + mode: "structure", + sql: "", + structureTableName: "users", + }), + ]); + + const restored = restoreOpenTabsState(JSON.stringify(raw), "data", { validConnectionIds: ["live-conn"] }); + + assert.deepEqual( + restored.tabs.map((tab) => ({ id: tab.id, mode: tab.mode })), + [{ id: "query", mode: "query" }], + ); + assert.equal(restored.activeTabId, "query"); +}); + test("restores MQ tabs with selected tenant context", () => { const raw = JSON.stringify([ queryTab({