diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 227177cadd..0f4bed7deb 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -3,6 +3,7 @@ import { ref, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch, sha import { CaseLower, CaseUpper, Code2, FileCode, Pencil, PencilRuler, Play, Copy, List, Search, Sparkles, Table2, TextSelect, Trash2 } from "@lucide/vue"; import { useI18n } from "vue-i18n"; import type { CompletionContext } from "@codemirror/autocomplete"; +import { Transaction } from "@codemirror/state"; import type { EditorView as EditorViewType } from "@codemirror/view"; import { search as cmSearch } from "@codemirror/search"; import EditorSearchPanel from "./EditorSearchPanel.vue"; @@ -68,12 +69,14 @@ import { focusEditorView } from "@/lib/editor/queryEditorFocus"; import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect"; import { sqlSemanticTableNameSpansForSyntaxTree } from "@/lib/editor/codemirrorSqlSemanticHighlight"; import { startsQueryEditorRectangularSelection } from "@/lib/editor/queryEditorPointerSelection"; +import { LARGE_PASTE_HISTORY_USER_EVENT, normalizeQueryEditorPasteText, recoverableNativePasteSuffix, shouldRecoverLargeTauriPaste } from "@/lib/editor/queryEditorLargePaste"; import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation"; import { isSchemaAware, isSingleDatabase, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport"; import { metadataSchemaForConnection } from "@/lib/database/jdbcDialect"; import { usesLocalOnlyEditorCompletionMetadata, usesOnDemandOnlyEditorColumnMetadata } from "@/lib/metadata/completionMetadataPolicy"; import { queryContextObjectActions, queryContextObjectRoute, queryTableCandidateAtSqlPosition, resolveQueryContextCandidateDatabase, resolveQueryContextObjectTarget, type QueryContextObjectAction } from "@/lib/sql/queryCursorTableTarget"; import * as api from "@/lib/backend/api"; +import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { areSqlSemanticDiagnosticsEqual, buildSqlParserErrorDiagnostic, @@ -1003,6 +1006,44 @@ async function pasteClipboardAsSqlInCondition(): Promise { return true; } +function recoverLargeTauriPaste(event: ClipboardEvent, currentView: EditorViewType): boolean { + const eventText = event.clipboardData?.getData("text/plain") ?? ""; + if (props.readOnly || currentView.state.selection.ranges.length !== 1 || !shouldRecoverLargeTauriPaste(eventText, isTauriRuntime())) return false; + + event.preventDefault(); + const selection = currentView.state.selection.main; + const insertedText = normalizeQueryEditorPasteText(eventText); + const insertedFrom = selection.from; + const insertedTo = insertedFrom + insertedText.length; + const pasteStartedAt = Date.now(); + currentView.dispatch({ + changes: { from: selection.from, to: selection.to, insert: insertedText }, + selection: { anchor: insertedTo }, + scrollIntoView: true, + // CodeMirror only joins input.type history events; keep one timestamp so delayed recovery is one undo step. + annotations: Transaction.time.of(pasteStartedAt), + userEvent: LARGE_PASTE_HISTORY_USER_EVENT, + }); + + void readTextFromClipboard() + .then((nativeText) => { + const suffix = recoverableNativePasteSuffix(eventText, nativeText); + if (!suffix || props.readOnly || view.value !== currentView) return; + if (currentView.state.doc.sliceString(insertedFrom, insertedTo) !== insertedText) return; + const currentSelection = currentView.state.selection.main; + const selectionRemainedAtPasteEnd = currentSelection.empty && currentSelection.head === insertedTo; + currentView.dispatch({ + changes: { from: insertedTo, insert: suffix }, + ...(selectionRemainedAtPasteEnd ? { selection: { anchor: insertedTo + suffix.length } } : {}), + scrollIntoView: selectionRemainedAtPasteEnd, + annotations: Transaction.time.of(pasteStartedAt), + userEvent: LARGE_PASTE_HISTORY_USER_EVENT, + }); + }) + .catch(() => {}); + return true; +} + function deleteEmptyLines() { const currentView = view.value; if (!currentView || props.readOnly) return; @@ -3567,6 +3608,9 @@ onMounted(async () => { }), ), EditorView.domEventHandlers({ + paste(event, currentView) { + return recoverLargeTauriPaste(event, currentView); + }, dragover(event) { if (props.readOnly || !hasDroppedTableReference(event)) return false; event.preventDefault(); diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorLargePaste.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorLargePaste.spec.ts new file mode 100644 index 0000000000..ea2902625b --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorLargePaste.spec.ts @@ -0,0 +1,66 @@ +import { readFileSync } from "node:fs"; +import { history, undo, undoDepth } from "@codemirror/commands"; +import { EditorState, Transaction } from "@codemirror/state"; +import { describe, expect, it } from "vitest"; +import { LARGE_PASTE_HISTORY_USER_EVENT, LARGE_PASTE_NATIVE_RECOVERY_THRESHOLD, normalizeQueryEditorPasteText, recoverableNativePasteSuffix, shouldRecoverLargeTauriPaste } from "@/lib/editor/queryEditorLargePaste"; + +const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8"); + +describe("QueryEditor large paste recovery", () => { + it("recovers the suffix of a SQL paste truncated at the WebView boundary", () => { + const sql = Array.from({ length: 10_925 }, (_, index) => `('Z${String(index).padStart(13, "0")}'),`).join("\r\n"); + const eventText = sql.slice(0, 128 * 1024); + + expect(shouldRecoverLargeTauriPaste(eventText, true)).toBe(true); + const suffix = recoverableNativePasteSuffix(eventText, sql); + expect(suffix).not.toBeNull(); + const recovered = normalizeQueryEditorPasteText(eventText) + suffix; + expect(recovered).toBe(normalizeQueryEditorPasteText(sql)); + expect(recovered.split("\n")).toHaveLength(10_925); + }); + + it("does not alter small, web, unchanged, or unrelated clipboard text", () => { + expect(shouldRecoverLargeTauriPaste("x".repeat(LARGE_PASTE_NATIVE_RECOVERY_THRESHOLD - 1), true)).toBe(false); + expect(shouldRecoverLargeTauriPaste("x".repeat(LARGE_PASTE_NATIVE_RECOVERY_THRESHOLD), false)).toBe(false); + expect(recoverableNativePasteSuffix("SELECT 1", "SELECT 1")).toBeNull(); + expect(recoverableNativePasteSuffix("SELECT 1", "SELECT 2 with more text")).toBeNull(); + }); + + it("preserves Unicode separators that CodeMirror does not treat as line breaks", () => { + expect(normalizeQueryEditorPasteText("SELECT '\u2028\u2029'\r\n")).toBe("SELECT '\u2028\u2029'\n"); + }); + + it("groups the WebView prefix and native suffix into one undo step", () => { + const pasteStartedAt = Date.now(); + let state = EditorState.create({ doc: "SELECT ", selection: { anchor: 7 }, extensions: [history()] }); + state = state.update({ + changes: { from: 7, insert: "prefix" }, + selection: { anchor: 13 }, + annotations: Transaction.time.of(pasteStartedAt), + userEvent: LARGE_PASTE_HISTORY_USER_EVENT, + }).state; + state = state.update({ + changes: { from: 13, insert: "suffix" }, + selection: { anchor: 19 }, + annotations: Transaction.time.of(pasteStartedAt), + userEvent: LARGE_PASTE_HISTORY_USER_EVENT, + }).state; + const view = { + get state() { + return state; + }, + dispatch(transaction: Transaction) { + state = transaction.state; + }, + } as unknown as Parameters[0]; + + expect(undoDepth(state)).toBe(1); + expect(undo(view)).toBe(true); + expect(state.doc.toString()).toBe("SELECT "); + expect(state.selection.main.from).toBe(7); + }); + + it("wires the recovery into the editor paste event", () => { + expect(queryEditorSource).toMatch(/paste\(event, currentView\)[\s\S]*?recoverLargeTauriPaste\(event, currentView\)/); + }); +}); diff --git a/apps/desktop/src/lib/editor/queryEditorLargePaste.ts b/apps/desktop/src/lib/editor/queryEditorLargePaste.ts new file mode 100644 index 0000000000..5370b61b3e --- /dev/null +++ b/apps/desktop/src/lib/editor/queryEditorLargePaste.ts @@ -0,0 +1,19 @@ +export const LARGE_PASTE_NATIVE_RECOVERY_THRESHOLD = 120 * 1024; +export const LARGE_PASTE_HISTORY_USER_EVENT = "input.type.paste"; + +export function normalizeQueryEditorPasteText(text: string): string { + return text.replace(/\r\n?/g, "\n"); +} + +export function shouldRecoverLargeTauriPaste(eventText: string, tauriRuntime: boolean): boolean { + if (!tauriRuntime || !eventText) return false; + if (eventText.length >= LARGE_PASTE_NATIVE_RECOVERY_THRESHOLD) return true; + return new TextEncoder().encode(eventText).byteLength >= LARGE_PASTE_NATIVE_RECOVERY_THRESHOLD; +} + +export function recoverableNativePasteSuffix(eventText: string, nativeText: string): string | null { + const normalizedEventText = normalizeQueryEditorPasteText(eventText); + const normalizedNativeText = normalizeQueryEditorPasteText(nativeText); + if (normalizedNativeText.length <= normalizedEventText.length || !normalizedNativeText.startsWith(normalizedEventText)) return null; + return normalizedNativeText.slice(normalizedEventText.length); +}