fix(frontend): guard startup and loading shortcuts

This commit is contained in:
zipg 2026-08-03 16:57:26 +08:00 committed by GitHub
parent 246dfad018
commit 658e3d16ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 420 additions and 15 deletions

View File

@ -47,6 +47,25 @@
root.style.colorScheme = dark ? "dark" : "light";
})();
</script>
<script data-dbx-startup-input-guard>
(() => {
const readyEvent = "dbx:startup-ready";
const preventStartupEscape = (event) => {
if (event.key !== "Escape") return;
event.preventDefault();
event.stopImmediatePropagation();
};
window.addEventListener("keydown", preventStartupEscape, true);
window.addEventListener(
readyEvent,
() => {
window.removeEventListener("keydown", preventStartupEscape, true);
},
{ once: true },
);
})();
</script>
</head>
<body>
<div id="root">

View File

@ -90,6 +90,7 @@ import { countAvailableAgentDriverUpdates, type AgentDriverUpdateBadgeState } fr
import type { DriverStoreFocus } from "@/lib/connection/agentDriverInstallHint";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { apiUrl, webPath } from "@/lib/common/webPath";
import { shouldBlockAppNativeSelectAll } from "@/lib/common/clipboard";
import { APP_FONT_SANS_CSS_VAR, DATA_GRID_FONT_FAMILY_CSS_VAR, DEFAULT_DATA_GRID_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY } from "@/lib/app/appFonts";
import { rankSavedSqlHistory } from "@/lib/savedSql/savedSqlHistory";
import { savedSqlDefaultTargetForWrite } from "@/lib/savedSql/savedSqlExecutionTarget";
@ -1873,6 +1874,10 @@ function activateAdjacentTab(direction: -1 | 1): boolean {
return activateTabByIndex(nextIndex);
}
function handleNativeSelectAll(e: KeyboardEvent) {
if (shouldBlockAppNativeSelectAll(e)) e.preventDefault();
}
function handleKeydown(e: KeyboardEvent) {
if (e.defaultPrevented) return;
@ -2138,6 +2143,7 @@ onMounted(async () => {
});
applyTheme();
void applyUiScale(settingsStore.editorSettings.uiScale);
window.addEventListener("keydown", handleNativeSelectAll, true);
window.addEventListener("keydown", handleKeydown);
window.addEventListener("dbx-open-driver-store", openDriverStoreFromEvent);
if (isDesktop) {
@ -2204,6 +2210,7 @@ onUnmounted(() => {
if (updateCheckTimer) {
clearInterval(updateCheckTimer);
}
window.removeEventListener("keydown", handleNativeSelectAll, true);
window.removeEventListener("keydown", handleKeydown);
window.removeEventListener("dbx-open-driver-store", openDriverStoreFromEvent);
document.removeEventListener("contextmenu", handleContextMenu);

View File

@ -0,0 +1,38 @@
// @vitest-environment happy-dom
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const indexSource = readFileSync(resolve(process.cwd(), "apps/desktop/index.html"), "utf8");
const mainSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/main.ts"), "utf8");
function installStartupInputGuard() {
const script = indexSource.match(/<script data-dbx-startup-input-guard>([\s\S]*?)<\/script>/)?.[1];
if (!script) throw new Error("Startup input guard script not found");
new Function(script)();
}
describe("startup input guard", () => {
it("prevents Escape until the app is mounted", () => {
installStartupInputGuard();
const startupEscape = new KeyboardEvent("keydown", { key: "Escape", cancelable: true });
const regularKey = new KeyboardEvent("keydown", { key: "a", cancelable: true });
window.dispatchEvent(startupEscape);
window.dispatchEvent(regularKey);
expect(startupEscape.defaultPrevented).toBe(true);
expect(regularKey.defaultPrevented).toBe(false);
window.dispatchEvent(new Event("dbx:startup-ready"));
const readyEscape = new KeyboardEvent("keydown", { key: "Escape", cancelable: true });
window.dispatchEvent(readyEscape);
expect(readyEscape.defaultPrevented).toBe(false);
});
it("installs before the application module and is released after mount", () => {
expect(indexSource.indexOf("data-dbx-startup-input-guard")).toBeLessThan(indexSource.indexOf('src="/src/main.ts"'));
expect(mainSource.indexOf('app.mount("#root")')).toBeLessThan(mainSource.indexOf('window.dispatchEvent(new Event("dbx:startup-ready"))'));
});
});

View File

@ -187,7 +187,8 @@ import { useToast } from "@/composables/useToast";
import { useNavigationTargets } from "@/composables/useNavigationTargets";
import { useDataGridExport, type MongoCopyUpdateTarget } from "@/composables/useDataGridExport";
import { eventTargetAllowsNativeClipboard, isPlainClipboardShortcut, readTextFromClipboard } from "@/lib/common/clipboard";
import { claimDataGridPaste, clearDataGridClipboardCopy, parseDataGridClipboard, planDataGridPaste } from "@/lib/dataGrid/dataGridClipboard";
import { claimDataGridPaste, claimDataGridSelectAll, clearDataGridClipboardCopy, parseDataGridClipboard, planDataGridPaste } from "@/lib/dataGrid/dataGridClipboard";
import { beginDataGridNativeSelectionBlock, finishDataGridNativeSelectionBlock } from "@/lib/dataGrid/dataGridNativeSelection";
import { DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS, DATA_GRID_COPY_EXTRACTOR_IDS, extractorUnavailableForDatabase, type DataGridCopyExtractorId } from "@/lib/dataGrid/dataGridCopyExtractor";
import { columnNamesForCopy } from "@/lib/dataGrid/dataGridColumnNameCopy";
import { DATA_GRID_ROW_NUM_WIDTH, dataGridRowNumberColumnWidth, resolveDataGridMaxRowNumber, useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
@ -2541,8 +2542,17 @@ const canFetchNextInfiniteScrollSegment = computed(() =>
);
const canJumpLastPage = computed(() => canGoNextPage.value && (hasKnownPaginationTotalRowCount.value || allRowsLoaded.value || !!props.tableMeta || !!props.countSql || !!props.countTotalRows));
const totalRowCountBusy = computed(() => props.totalRowCountLoading === true || manualTotalRowCountLoading.value);
/** Full-grid busy state: query loading or on-demand COUNT (e.g. jump to last page). */
const gridSurfaceBusy = computed(() => props.loading === true || totalRowCountBusy.value);
/** Full-grid busy state: refresh dispatch, query loading, or on-demand COUNT (e.g. jump to last page). */
const gridSurfaceBusy = computed(() => isRefreshingData.value || props.loading === true || totalRowCountBusy.value);
const dataGridNativeSelectionBlockOwner = {};
watch(
gridSurfaceBusy,
(busy, prevBusy) => {
if (busy) beginDataGridNativeSelectionBlock(dataGridNativeSelectionBlockOwner);
else if (prevBusy) finishDataGridNativeSelectionBlock(dataGridNativeSelectionBlockOwner);
},
{ immediate: true },
);
const canCalculateTotalRowCount = computed(() => !!props.countTotalRows || (!!props.connectionId && (!!props.tableMeta || !!props.countSql)));
const showExactTotalCountAction = computed(() => canCalculateTotalRowCount.value && (totalRowCountIsExact.value === false || typeof displayedTotalRowCount.value !== "number"));
watch(
@ -3288,6 +3298,7 @@ async function onToolbarRefresh() {
}
preserveTransposeOnNextResult.value = showTranspose.value;
isRefreshingData.value = true;
beginDataGridNativeSelectionBlock(dataGridNativeSelectionBlockOwner);
emit("reload", props.sql, searchText.value, currentWhereInput(), currentOrderBy(), pageSize.value, (currentPage.value - 1) * pageSize.value, "refresh");
}
@ -5374,6 +5385,8 @@ watch(
function pauseCanvasGridWork() {
dataGridIsActive = false;
stopLoadingElapsedTimer();
if (gridSurfaceBusy.value) finishDataGridNativeSelectionBlock(dataGridNativeSelectionBlockOwner);
canvasRuntime.pause();
gridScrollbarsRuntime.pause();
disconnectCellEditResizeObserver();
@ -5386,6 +5399,8 @@ function pauseCanvasGridWork() {
function resumeCanvasGridWork() {
dataGridIsActive = true;
startLoadingElapsedTimer();
if (gridSurfaceBusy.value) beginDataGridNativeSelectionBlock(dataGridNativeSelectionBlockOwner);
canvasRuntime.resume();
gridScrollbarsRuntime.resume();
nextTick(() => {
@ -6537,8 +6552,8 @@ async function onGridKeydown(event: KeyboardEvent) {
return;
}
if (clipboardShortcut(event, "a")) {
if (!hasData.value) return;
event.preventDefault();
const intent = claimDataGridSelectAll(event, gridSurfaceBusy.value, hasData.value);
if (intent !== "select") return;
selectAllCells();
return;
}
@ -7903,11 +7918,21 @@ function stopLoadingElapsedTimer() {
}
}
function startLoadingElapsedTimer() {
function startLoadingElapsedTimer(reset = false) {
stopLoadingElapsedTimer();
if (!dataGridIsActive || !gridSurfaceBusy.value) return;
_loadingStart = Date.now();
loadingElapsed.value = 0;
if (!dataGridIsActive || !gridSurfaceBusy.value) {
if (!gridSurfaceBusy.value) {
_loadingStart = 0;
loadingElapsed.value = 0;
}
return;
}
if (reset || !_loadingStart) {
_loadingStart = Date.now();
loadingElapsed.value = 0;
} else {
loadingElapsed.value = Date.now() - _loadingStart;
}
const updateOnNextFrame = () => {
if (!dataGridIsActive || !gridSurfaceBusy.value) return;
loadingElapsed.value = Date.now() - _loadingStart;
@ -7926,7 +7951,7 @@ watch(gridSurfaceBusy, (isLoading) => {
});
}
if (isLoading) {
startLoadingElapsedTimer();
startLoadingElapsedTimer(true);
} else if (isDebugLoggingEnabled()) {
nextTick(() => {
requestAnimationFrame(() => {
@ -7941,11 +7966,9 @@ watch(gridSurfaceBusy, (isLoading) => {
});
onActivated(() => {
startLoadingElapsedTimer();
autoRefresh.start();
});
onDeactivated(() => {
stopLoadingElapsedTimer();
autoRefresh.stop();
});

View File

@ -0,0 +1,14 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8");
describe("DataGrid loading elapsed timer", () => {
it("resets for a new load but resumes from the original start after KeepAlive activation", () => {
expect(dataGridSource).toContain("function startLoadingElapsedTimer(reset = false)");
expect(dataGridSource).toContain("if (reset || !_loadingStart)");
expect(dataGridSource).toContain("startLoadingElapsedTimer(true);");
expect(dataGridSource).toMatch(/function resumeCanvasGridWork\(\) \{\s*dataGridIsActive = true;\s*startLoadingElapsedTimer\(\);/);
expect(dataGridSource).toMatch(/function pauseCanvasGridWork\(\) \{\s*dataGridIsActive = false;\s*stopLoadingElapsedTimer\(\);/);
});
});

View File

@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { copyToClipboard } from "@/lib/common/clipboard";
import { claimDataGridPaste, clearDataGridClipboardCopy, parseDataGridClipboard, planDataGridPaste, rememberDataGridClipboardCopy } from "@/lib/dataGrid/dataGridClipboard";
import { claimDataGridPaste, claimDataGridSelectAll, clearDataGridClipboardCopy, parseDataGridClipboard, planDataGridPaste, rememberDataGridClipboardCopy } from "@/lib/dataGrid/dataGridClipboard";
afterEach(() => clearDataGridClipboardCopy());
@ -19,6 +19,32 @@ function pasteEvent(nativeClipboard: boolean) {
};
}
describe("claimDataGridSelectAll", () => {
it("blocks native page selection while the grid is loading", () => {
const event = pasteEvent(false);
expect(claimDataGridSelectAll(event, true, false)).toBe("block");
expect(event.preventDefault).toHaveBeenCalledOnce();
});
it("selects grid data after loading", () => {
const event = pasteEvent(false);
expect(claimDataGridSelectAll(event, false, true)).toBe("select");
expect(event.preventDefault).toHaveBeenCalledOnce();
});
it("keeps native selection for editors and empty idle grids", () => {
const editorEvent = pasteEvent(true);
const emptyGridEvent = pasteEvent(false);
expect(claimDataGridSelectAll(editorEvent, true, false)).toBe("native");
expect(claimDataGridSelectAll(emptyGridEvent, false, false)).toBe("native");
expect(editorEvent.preventDefault).not.toHaveBeenCalled();
expect(emptyGridEvent.preventDefault).not.toHaveBeenCalled();
});
});
describe("claimDataGridPaste", () => {
it("keeps native paste behavior for editors inside the grid", () => {
const event = pasteEvent(true);

View File

@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { beginDataGridNativeSelectionBlock, DATA_GRID_NATIVE_SELECTION_BLOCK_CLASS, DATA_GRID_NATIVE_SELECTION_RELEASE_DELAY_MS, finishDataGridNativeSelectionBlock } from "@/lib/dataGrid/dataGridNativeSelection";
function selectionEnvironment() {
const classes = new Set<string>();
const removeAllRanges = vi.fn();
return {
classes,
removeAllRanges,
environment: {
document: {
documentElement: {
classList: {
add: (className: string) => classes.add(className),
remove: (className: string) => classes.delete(className),
},
},
},
getSelection: () => ({ removeAllRanges }),
setTimeout,
clearTimeout,
},
};
}
afterEach(() => {
vi.useRealTimers();
});
describe("data grid native selection block", () => {
it("blocks native selection immediately and clears an existing selection", () => {
const { classes, removeAllRanges, environment } = selectionEnvironment();
const owner = {};
beginDataGridNativeSelectionBlock(owner, environment);
expect(classes.has(DATA_GRID_NATIVE_SELECTION_BLOCK_CLASS)).toBe(true);
expect(removeAllRanges).toHaveBeenCalledOnce();
});
it("keeps the application-level block across a fast component replacement", () => {
vi.useFakeTimers();
const { classes, environment } = selectionEnvironment();
const owner = {};
beginDataGridNativeSelectionBlock(owner, environment);
finishDataGridNativeSelectionBlock(owner, environment);
vi.advanceTimersByTime(DATA_GRID_NATIVE_SELECTION_RELEASE_DELAY_MS - 1);
expect(classes.has(DATA_GRID_NATIVE_SELECTION_BLOCK_CLASS)).toBe(true);
vi.advanceTimersByTime(1);
expect(classes.has(DATA_GRID_NATIVE_SELECTION_BLOCK_CLASS)).toBe(false);
});
it("cancels a pending release when another grid starts loading", () => {
vi.useFakeTimers();
const { classes, environment } = selectionEnvironment();
const owner = {};
finishDataGridNativeSelectionBlock(owner, environment);
vi.advanceTimersByTime(DATA_GRID_NATIVE_SELECTION_RELEASE_DELAY_MS - 1);
beginDataGridNativeSelectionBlock(owner, environment);
vi.advanceTimersByTime(DATA_GRID_NATIVE_SELECTION_RELEASE_DELAY_MS);
expect(classes.has(DATA_GRID_NATIVE_SELECTION_BLOCK_CLASS)).toBe(true);
});
it("does not let another grid release the application-level block", () => {
vi.useFakeTimers();
const { classes, environment } = selectionEnvironment();
const loadingGrid = {};
const finishedGrid = {};
beginDataGridNativeSelectionBlock(loadingGrid, environment);
finishDataGridNativeSelectionBlock(finishedGrid, environment);
vi.advanceTimersByTime(DATA_GRID_NATIVE_SELECTION_RELEASE_DELAY_MS);
expect(classes.has(DATA_GRID_NATIVE_SELECTION_BLOCK_CLASS)).toBe(true);
finishDataGridNativeSelectionBlock(loadingGrid, environment);
vi.advanceTimersByTime(DATA_GRID_NATIVE_SELECTION_RELEASE_DELAY_MS);
expect(classes.has(DATA_GRID_NATIVE_SELECTION_BLOCK_CLASS)).toBe(false);
});
});

View File

@ -0,0 +1,44 @@
import { describe, expect, it, vi } from "vitest";
import { clearStartupPreloadRetry, retryStartupAfterPreloadFailure } from "@/lib/startup/startupPreloadRecovery";
function environment() {
const values = new Map<string, string>();
return {
values,
env: {
sessionStorage: {
getItem: vi.fn((key: string) => values.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => values.set(key, value)),
removeItem: vi.fn((key: string) => values.delete(key)),
},
location: { reload: vi.fn() },
},
};
}
describe("startup preload recovery", () => {
it("reloads once after a CSS preload failure", () => {
const { env } = environment();
const error = new Error("Unable to preload CSS for http://tauri.localhost/assets/QueryLoadingState.css");
expect(retryStartupAfterPreloadFailure(error, env)).toBe(true);
expect(env.location.reload).toHaveBeenCalledOnce();
expect(retryStartupAfterPreloadFailure(error, env)).toBe(false);
expect(env.location.reload).toHaveBeenCalledOnce();
});
it("does not reload for unrelated startup failures", () => {
const { env } = environment();
expect(retryStartupAfterPreloadFailure(new Error("Database initialization failed"), env)).toBe(false);
expect(env.location.reload).not.toHaveBeenCalled();
});
it("clears the retry marker after startup succeeds", () => {
const { env, values } = environment();
values.set("dbx-startup-preload-retry", "1");
clearStartupPreloadRetry(env);
expect(values.has("dbx-startup-preload-retry")).toBe(false);
});
});

View File

@ -58,6 +58,7 @@ interface NativeClipboardSelectionEnvironment {
const EDITABLE_CLIPBOARD_TARGET_SELECTOR = "input, textarea, [contenteditable='true'], [role='textbox']";
const NATIVE_CLIPBOARD_REGION_SELECTOR = "[data-native-clipboard]";
const DATA_GRID_ROOT_SELECTOR = "[data-grid-root]";
let clipboardWriteRevision = 0;
export function getClipboardWriteRevision(): number {
@ -86,6 +87,10 @@ export function isPlainClipboardShortcut(event: ClipboardShortcutEvent, key: str
return !!(event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === key;
}
export function shouldBlockAppNativeSelectAll(event: ClipboardShortcutEvent): boolean {
return isPlainClipboardShortcut(event, "a") && !eventTargetUsesNativeClipboard(event) && !closestElement(event.target, DATA_GRID_ROOT_SELECTOR);
}
export function hasNativeClipboardSelection(env: NativeClipboardSelectionEnvironment = globalThis as unknown as NativeClipboardSelectionEnvironment): boolean {
const selection = env.getSelection?.();
if (!selection || selection.isCollapsed) return false;

View File

@ -3,6 +3,7 @@ import { displayCellValue, type CellValue } from "@/lib/dataGrid/cellValue";
import { parseClipboardTable } from "@/lib/dataGrid/gridSelection";
export type DataGridPasteIntent = "native" | "block" | "paste";
export type DataGridSelectAllIntent = "native" | "block" | "select";
export interface DataGridPasteCell {
rowOffset: number;
@ -24,6 +25,17 @@ interface DataGridPasteEvent {
stopPropagation(): void;
}
interface DataGridSelectAllEvent {
target?: EventTarget | null;
preventDefault(): void;
}
export function claimDataGridSelectAll(event: DataGridSelectAllEvent, loading: boolean, hasData: boolean): DataGridSelectAllIntent {
if (eventTargetUsesNativeClipboard(event) || (!loading && !hasData)) return "native";
event.preventDefault();
return loading ? "block" : "select";
}
export function claimDataGridPaste(event: DataGridPasteEvent, editable: boolean, hasSelection: boolean): DataGridPasteIntent {
if (eventTargetUsesNativeClipboard(event)) return "native";
event.preventDefault();

View File

@ -0,0 +1,60 @@
export const DATA_GRID_NATIVE_SELECTION_BLOCK_CLASS = "dbx-data-grid-native-selection-blocked";
export const DATA_GRID_NATIVE_SELECTION_RELEASE_DELAY_MS = 2000;
interface NativeSelectionBlockEnvironment {
document?: {
documentElement?: {
classList: {
add(className: string): void;
remove(className: string): void;
};
};
};
getSelection?: () => { removeAllRanges(): void } | null;
setTimeout(callback: () => void, delay: number): ReturnType<typeof setTimeout>;
clearTimeout(timer: ReturnType<typeof setTimeout>): void;
}
export type DataGridNativeSelectionBlockOwner = object;
interface ActiveSelectionBlock {
environment: NativeSelectionBlockEnvironment;
releaseTimer?: ReturnType<typeof setTimeout>;
}
const activeSelectionBlocks = new Map<DataGridNativeSelectionBlockOwner, ActiveSelectionBlock>();
function defaultEnvironment(): NativeSelectionBlockEnvironment {
return globalThis as unknown as NativeSelectionBlockEnvironment;
}
function clearReleaseTimer(owner: DataGridNativeSelectionBlockOwner) {
const activeBlock = activeSelectionBlocks.get(owner);
if (activeBlock?.releaseTimer === undefined) return;
activeBlock.environment.clearTimeout(activeBlock.releaseTimer);
activeBlock.releaseTimer = undefined;
}
function hasActiveBlockForEnvironment(environment: NativeSelectionBlockEnvironment) {
const documentElement = environment.document?.documentElement;
return [...activeSelectionBlocks.values()].some((activeBlock) => activeBlock.environment.document?.documentElement === documentElement);
}
export function beginDataGridNativeSelectionBlock(owner: DataGridNativeSelectionBlockOwner, environment: NativeSelectionBlockEnvironment = defaultEnvironment()) {
clearReleaseTimer(owner);
activeSelectionBlocks.set(owner, { environment });
environment.document?.documentElement?.classList.add(DATA_GRID_NATIVE_SELECTION_BLOCK_CLASS);
environment.getSelection?.()?.removeAllRanges();
}
export function finishDataGridNativeSelectionBlock(owner: DataGridNativeSelectionBlockOwner, environment: NativeSelectionBlockEnvironment = defaultEnvironment(), delay = DATA_GRID_NATIVE_SELECTION_RELEASE_DELAY_MS) {
beginDataGridNativeSelectionBlock(owner, environment);
const activeBlock = activeSelectionBlocks.get(owner);
if (!activeBlock) return;
activeBlock.releaseTimer = environment.setTimeout(() => {
activeSelectionBlocks.delete(owner);
if (!hasActiveBlockForEnvironment(environment)) {
environment.document?.documentElement?.classList.remove(DATA_GRID_NATIVE_SELECTION_BLOCK_CLASS);
}
}, delay);
}

View File

@ -0,0 +1,31 @@
const STARTUP_PRELOAD_RETRY_KEY = "dbx-startup-preload-retry";
interface StartupPreloadRecoveryEnvironment {
sessionStorage: Pick<Storage, "getItem" | "setItem" | "removeItem">;
location: Pick<Location, "reload">;
}
function errorText(error: unknown): string {
if (error instanceof Error) return [error.message, error.stack].filter(Boolean).join("\n");
return String(error);
}
export function retryStartupAfterPreloadFailure(error: unknown, env: StartupPreloadRecoveryEnvironment = window): boolean {
if (!errorText(error).includes("Unable to preload CSS")) return false;
try {
if (env.sessionStorage.getItem(STARTUP_PRELOAD_RETRY_KEY) === "1") return false;
env.sessionStorage.setItem(STARTUP_PRELOAD_RETRY_KEY, "1");
} catch {
return false;
}
env.location.reload();
return true;
}
export function clearStartupPreloadRetry(env: Pick<StartupPreloadRecoveryEnvironment, "sessionStorage"> = window): void {
try {
env.sessionStorage.removeItem(STARTUP_PRELOAD_RETRY_KEY);
} catch {
// Storage can be unavailable in restricted WebView environments.
}
}

View File

@ -4,6 +4,7 @@ import VueVirtualScroller from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import "./styles/globals.css";
import { installDebugLogCapture } from "@/lib/backend/debugLog";
import { clearStartupPreloadRetry, retryStartupAfterPreloadFailure } from "@/lib/startup/startupPreloadRecovery";
function startupErrorMessage(error: unknown): string {
if (error instanceof Error) {
@ -13,6 +14,7 @@ function startupErrorMessage(error: unknown): string {
}
function renderStartupError(error: unknown) {
if (retryStartupAfterPreloadFailure(error)) return;
const message = startupErrorMessage(error);
console.error("[STARTUP] bootstrap failed", error);
const root = document.querySelector<HTMLDivElement>("#root");
@ -84,6 +86,8 @@ async function bootstrap() {
app.use(i18n);
app.use(VueVirtualScroller);
app.mount("#root");
clearStartupPreloadRetry();
window.dispatchEvent(new Event("dbx:startup-ready"));
console.log("[STARTUP] vue mounted");
installGlobalInputAttrs();

View File

@ -45,6 +45,7 @@ import { buildTableSelectSql, quoteTableDataIdentifier } from "@/lib/table/table
import { connectionQueryExecutionSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, metadataSchemaForConnection } from "@/lib/database/jdbcDialect";
import { frontendQueryTimeoutSecsForSql, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
import { queryResultNameFromPreamble, queryResultSourceLabel } from "@/lib/sql/queryResultSource";
import { beginDataGridNativeSelectionBlock, finishDataGridNativeSelectionBlock } from "@/lib/dataGrid/dataGridNativeSelection";
import { simpleDataGridOrderByReferencesMissingColumn, sortDataGridRowIndexes, type DataGridSortDirection } from "@/lib/dataGrid/dataGridSort";
import { MAX_RESULT_PAGE_SIZE, normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
import { elasticsearchRestRequestRanges, executableStatementRanges, splitSqlStatementRanges } from "@/lib/sql/sqlStatementRanges";
@ -3336,6 +3337,8 @@ export const useQueryStore = defineStore("query", () => {
tab.queryExecutionStartedAt = Date.now();
}
tab.executionId = executionId;
const tableDataNativeSelectionBlockOwner = tab.mode === "data" ? {} : undefined;
if (tableDataNativeSelectionBlockOwner) beginDataGridNativeSelectionBlock(tableDataNativeSelectionBlockOwner);
const previousDisplayedSql = tab.resultBaseSql ?? tab.lastExecutedSql ?? tab.sql;
tab.lastExecutedSql = sql;
tab.resultLocalSortOriginalRows = undefined;
@ -4227,6 +4230,7 @@ export const useQueryStore = defineStore("query", () => {
syncDisplayedResultRun(current, queryBaseSql, openInNewResultTab);
}
} finally {
if (tableDataNativeSelectionBlockOwner) finishDataGridNativeSelectionBlock(tableDataNativeSelectionBlockOwner);
const current = tabs.value.find((t) => t.id === id);
if (current?.executionId === executionId) {
const liveBatch = liveBatchSqlExecutions.get(current);

View File

@ -1500,6 +1500,22 @@ body.dbx-table-reference-dragging * {
-webkit-user-select: none !important;
}
html.dbx-data-grid-native-selection-blocked body,
html.dbx-data-grid-native-selection-blocked body * {
user-select: none !important;
-webkit-user-select: none !important;
}
html.dbx-data-grid-native-selection-blocked input,
html.dbx-data-grid-native-selection-blocked textarea,
html.dbx-data-grid-native-selection-blocked [contenteditable="true"],
html.dbx-data-grid-native-selection-blocked [contenteditable="true"] *,
html.dbx-data-grid-native-selection-blocked [role="textbox"],
html.dbx-data-grid-native-selection-blocked [role="textbox"] * {
user-select: text !important;
-webkit-user-select: text !important;
}
@layer base {
* {
@apply border-border outline-ring/50;

View File

@ -1,6 +1,6 @@
import { strict as assert } from "node:assert";
import { test, vi } from "vitest";
import { copyToClipboard, eventTargetAllowsAppClipboardShortcut, eventTargetAllowsNativeClipboard, eventTargetUsesNativeClipboard, hasNativeClipboardSelection, isPlainClipboardShortcut, readTextFromClipboard, type ClipboardEnvironment } from "../../apps/desktop/src/lib/common/clipboard.ts";
import { copyToClipboard, eventTargetAllowsAppClipboardShortcut, eventTargetAllowsNativeClipboard, eventTargetUsesNativeClipboard, hasNativeClipboardSelection, isPlainClipboardShortcut, readTextFromClipboard, shouldBlockAppNativeSelectAll, type ClipboardEnvironment } from "../../apps/desktop/src/lib/common/clipboard.ts";
const tauriClipboardMock = vi.hoisted(() => ({
writeText: vi.fn<(text: string) => Promise<void>>(),
@ -144,6 +144,22 @@ test("clipboard shortcut detection requires a plain mod shortcut", () => {
assert.equal(isPlainClipboardShortcut({ key: "c", altKey: true }, "c"), false);
});
test("app native select-all is blocked outside editable controls", () => {
const containerTarget = { closest: () => null } as unknown as EventTarget;
const inputTarget = {
closest: (selector: string) => (selector.includes("input") ? {} : null),
} as unknown as EventTarget;
const dataGridTarget = {
closest: (selector: string) => (selector.includes("data-grid-root") ? {} : null),
} as unknown as EventTarget;
assert.equal(shouldBlockAppNativeSelectAll({ key: "a", metaKey: true, target: containerTarget }), true);
assert.equal(shouldBlockAppNativeSelectAll({ key: "A", ctrlKey: true, target: containerTarget }), true);
assert.equal(shouldBlockAppNativeSelectAll({ key: "a", metaKey: true, target: inputTarget }), false);
assert.equal(shouldBlockAppNativeSelectAll({ key: "a", metaKey: true, target: dataGridTarget }), false);
assert.equal(shouldBlockAppNativeSelectAll({ key: "a", metaKey: true, shiftKey: true, target: containerTarget }), false);
});
test("eventTargetAllowsNativeClipboard lets editable targets keep clipboard shortcuts", () => {
const inputTarget = {
closest: (selector: string) => (selector.includes("input") ? {} : null),

View File

@ -166,7 +166,7 @@ test("auto-redirect: total is undefined — guard prevents redirect attempt", ()
test("last-page COUNT shows grid busy overlay before executeQuery", () => {
const source = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8");
assert.match(source, /const gridSurfaceBusy = computed\(\(\) => props\.loading === true \|\| totalRowCountBusy\.value\)/);
assert.match(source, /const gridSurfaceBusy = computed\(\(\) => isRefreshingData\.value \|\| props\.loading === true \|\| totalRowCountBusy\.value\)/);
assert.match(source, /v-if="gridSurfaceBusy"/);
assert.match(source, /async function beginManualTotalRowCount/);
assert.match(source, /await nextTick\(\);/);