fix(grid): improve filter condition expansion

This commit is contained in:
zipg 2026-07-29 18:42:01 +08:00 committed by GitHub
parent 52d5c99a92
commit 1a6e00acbb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 180 additions and 24 deletions

View File

@ -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);

View File

@ -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"
/>
<div class="data-grid-topbar-condition-floating-controls pointer-events-none absolute inset-x-2 z-[1] flex h-6 min-w-0 items-center gap-1">
<span class="data-grid-topbar-condition-label" :class="[props.kind === 'where' ? 'data-grid-topbar-condition-label--where' : 'data-grid-topbar-condition-label--order', { 'data-grid-topbar-condition-label--compact': props.compact }]">
<div class="data-grid-topbar-condition-floating-controls pointer-events-none absolute inset-x-2 z-[2] flex h-6 min-w-0 items-center gap-1">
<span class="data-grid-topbar-condition-label data-grid-topbar-condition-label--floating" :class="[props.kind === 'where' ? 'data-grid-topbar-condition-label--where' : 'data-grid-topbar-condition-label--order', { 'data-grid-topbar-condition-label--compact': props.compact }]">
{{ props.kind === "where" ? "WHERE" : "ORDER BY" }}
</span>
<div class="min-w-0 flex-1" />
@ -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;

View File

@ -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*\{(?<body>[\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*\{(?<body>[\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*\{(?<body>[\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");
});
});

View File

@ -61,6 +61,11 @@ export interface RestoredOpenTabs {
}
export type OpenTabsRestoreFilter = "all" | "pinned";
export interface OpenTabsRestoreOptions {
queryOnly?: boolean;
filter?: OpenTabsRestoreFilter;
validConnectionIds?: Iterable<string>;
}
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 {

View File

@ -1,4 +1,4 @@
export const DATA_GRID_COMPACT_TOPBAR_WIDTH = 900;
export const DATA_GRID_COMPACT_TOPBAR_WIDTH = 1050;
export type DataGridReloadIntent = "refresh";

View File

@ -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<string> } = {}): { 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<string> } = {}): { 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<string> } = {}) {
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

View File

@ -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({