diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 82a7c5d8a5..c143ca8d5a 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -571,8 +571,10 @@ let buildSqlSemanticHighlightExtension: (() => import("@codemirror/state").Exten let codeMirrorSnippetCompletion: typeof import("@codemirror/autocomplete").snippetCompletion; let codeMirrorCompletionStatus: typeof import("@codemirror/autocomplete").completionStatus | null = null; let codeMirrorAcceptCompletion: typeof import("@codemirror/autocomplete").acceptCompletion | null = null; +let codeMirrorCurrentCompletions: typeof import("@codemirror/autocomplete").currentCompletions | null = null; let codeMirrorSelectedCompletionIndex: typeof import("@codemirror/autocomplete").selectedCompletionIndex | null = null; let codeMirrorSelectedCompletion: typeof import("@codemirror/autocomplete").selectedCompletion | null = null; +let codeMirrorSetSelectedCompletion: typeof import("@codemirror/autocomplete").setSelectedCompletion | null = null; let codeMirrorMoveCompletionSelection: typeof import("@codemirror/autocomplete").moveCompletionSelection | null = null; let codeMirrorSelectFirstCompletion: import("@codemirror/view").Command | null = null; let codeMirrorStartCompletion: typeof import("@codemirror/autocomplete").startCompletion | null = null; @@ -3605,11 +3607,158 @@ interface BatchColumnSelectionActionItem { type QueryCompletionOption = Completion & { dbxBatchColumnSelection?: { sessionKey: string; candidateKey: string }; + dbxBatchColumnSelectionAction?: { sessionKey: string }; }; let batchColumnSelectionSession: BatchColumnSelectionSession | null = null; +type BatchColumnSelectionCheckboxMarker = NonNullable; +interface BatchColumnSelectionDragState { + view: EditorViewType; + sessionKey: string; + pointerId: number; + anchorCandidateKey: string; + baseSelectedKeys: Set; + selected: boolean; + focusCandidateKey: string; + previousUserSelect: string; + scrollElement: HTMLElement | null; + pointerClientX: number; + pointerClientY: number; + autoScrollFrame: number; +} + +const batchColumnSelectionCheckboxMarkers = new WeakMap(); +const batchColumnSelectionActionMarkers = new WeakMap(); +const batchColumnSelectionTooltipParents = new WeakMap(); +let batchColumnSelectionDragState: BatchColumnSelectionDragState | null = null; +let batchColumnSelectionRefreshCleanup: (() => void) | null = null; +let batchColumnSelectionExpandedRendering = false; +let batchColumnSelectionRenderLimitTimer: number | null = null; + +function cancelBatchColumnSelectionRefresh() { + batchColumnSelectionRefreshCleanup?.(); + batchColumnSelectionRefreshCleanup = null; +} + +function setBatchColumnSelectionExpandedRendering(expanded: boolean) { + if (batchColumnSelectionExpandedRendering === expanded) return; + batchColumnSelectionExpandedRendering = expanded; + if (batchColumnSelectionRenderLimitTimer !== null) window.clearTimeout(batchColumnSelectionRenderLimitTimer); + + const currentView = view.value; + if (!currentView || !completionComp || !buildSqlCompletionExtension) return; + batchColumnSelectionRenderLimitTimer = window.setTimeout(() => { + batchColumnSelectionRenderLimitTimer = null; + if (view.value !== currentView || !completionComp || !buildSqlCompletionExtension) return; + currentView.dispatch({ effects: completionComp.reconfigure(buildSqlCompletionExtension()) }); + if (expanded && codeMirrorCompletionStatus?.(currentView.state) === "active") codeMirrorStartCompletion?.(currentView); + }, 0); +} + +function setBatchColumnSelectionValue(sessionKey: string, candidateKey: string, selected: boolean, checkbox?: HTMLInputElement) { + const session = batchColumnSelectionSession; + if (!session || session.key !== sessionKey || !session.candidates.some((candidate) => candidate.key === candidateKey)) return; + if (selected) session.selectedKeys.add(candidateKey); + else session.selectedKeys.delete(candidateKey); + if (checkbox) checkbox.checked = selected; +} + +function updateBatchColumnSelectionActionLabel(view: EditorViewType, sessionKey: string) { + const session = batchColumnSelectionSession; + if (!session || session.key !== sessionKey) return; + const label = t("editor.completion.insertSelectedColumns", { count: session.selectedKeys.size }); + const tooltipParent = batchColumnSelectionTooltipParents.get(view) ?? view.dom; + tooltipParent.querySelectorAll(".cm-batch-column-selection-action-marker").forEach((marker) => { + if (batchColumnSelectionActionMarkers.get(marker) !== sessionKey) return; + const element = marker.closest("li")?.querySelector(".cm-completionLabel"); + if (!element) return; + if (element.textContent !== label) element.textContent = label; + }); +} + +function scheduleBatchColumnSelectionRefresh(view: EditorViewType, sessionKey: string, focusCandidateKey: string, scrollState?: { element: HTMLElement | null; top: number; left: number }) { + cancelBatchColumnSelectionRefresh(); + const preservedScroll = + scrollState ?? + (() => { + const element = + visibleBatchColumnSelectionCheckboxes(sessionKey) + .map(({ checkbox }) => batchColumnSelectionScrollElement(checkbox)) + .find((candidate): candidate is HTMLElement => !!candidate) ?? null; + return { element, top: element?.scrollTop ?? 0, left: element?.scrollLeft ?? 0 }; + })(); + const restoreScroll = () => { + const currentElement = + visibleBatchColumnSelectionCheckboxes(sessionKey) + .map(({ checkbox }) => batchColumnSelectionScrollElement(checkbox)) + .find((candidate): candidate is HTMLElement => !!candidate) ?? preservedScroll.element; + if (!currentElement) return; + currentElement.scrollTop = Math.min(preservedScroll.top, Math.max(0, currentElement.scrollHeight - currentElement.clientHeight)); + currentElement.scrollLeft = Math.min(preservedScroll.left, Math.max(0, currentElement.scrollWidth - currentElement.clientWidth)); + }; + let restoreAttempts = 0; + let restoreFrame = 0; + let restoreTimer = 0; + let restoreStartTimer = 0; + let stopped = false; + const restoreObserver = typeof MutationObserver === "undefined" ? null : new MutationObserver(restoreScroll); + const stopRestore = () => { + if (stopped) return; + stopped = true; + restoreObserver?.disconnect(); + if (restoreFrame) window.cancelAnimationFrame(restoreFrame); + if (restoreTimer) window.clearTimeout(restoreTimer); + if (restoreStartTimer) window.clearTimeout(restoreStartTimer); + if (batchColumnSelectionRefreshCleanup === stopRestore) batchColumnSelectionRefreshCleanup = null; + }; + batchColumnSelectionRefreshCleanup = stopRestore; + const restoreNextFrame = () => { + if (batchColumnSelectionSession?.key !== sessionKey) { + stopRestore(); + return; + } + restoreScroll(); + restoreAttempts += 1; + if (restoreAttempts < 60) restoreFrame = window.requestAnimationFrame(restoreNextFrame); + else stopRestore(); + }; + restoreStartTimer = window.setTimeout(() => { + if (stopped || batchColumnSelectionSession?.key !== sessionKey) { + stopRestore(); + return; + } + restoreObserver?.observe(document.body, { childList: true, subtree: true }); + // Keep CodeMirror's virtualized range anchored to the item where the drag ended. + const focusIndex = + codeMirrorCurrentCompletions?.(view.state).findIndex((completion) => { + const marker = (completion as QueryCompletionOption).dbxBatchColumnSelection; + return marker?.sessionKey === sessionKey && marker.candidateKey === focusCandidateKey; + }) ?? -1; + if (focusIndex >= 0 && codeMirrorSetSelectedCompletion) view.dispatch({ effects: codeMirrorSetSelectedCompletion(focusIndex) }); + codeMirrorStartCompletion?.(view); + restoreNextFrame(); + restoreTimer = window.setTimeout(stopRestore, 1500); + }, 0); +} + +function finishBatchColumnSelectionDrag(refresh = true) { + const state = batchColumnSelectionDragState; + if (!state) return; + const scrollState = state.scrollElement ? { element: state.scrollElement, top: state.scrollElement.scrollTop, left: state.scrollElement.scrollLeft } : undefined; + batchColumnSelectionDragState = null; + if (state.autoScrollFrame) window.cancelAnimationFrame(state.autoScrollFrame); + window.removeEventListener("pointermove", onBatchColumnSelectionPointerMove, true); + window.removeEventListener("pointerup", onBatchColumnSelectionPointerUp, true); + window.removeEventListener("pointercancel", onBatchColumnSelectionPointerCancel, true); + window.removeEventListener("blur", onBatchColumnSelectionPointerCancel, true); + document.body.style.userSelect = state.previousUserSelect; + if (refresh) scheduleBatchColumnSelectionRefresh(state.view, state.sessionKey, state.focusCandidateKey, scrollState); +} function clearBatchColumnSelectionSession() { + finishBatchColumnSelectionDrag(false); + cancelBatchColumnSelectionRefresh(); + setBatchColumnSelectionExpandedRendering(false); batchColumnSelectionSession = null; } @@ -3624,7 +3773,7 @@ function batchColumnSelectionCandidateKey(item: SqlCompletionItem): string { function prepareBatchColumnSelectionSession(items: SqlCompletionItem[], document: string, from: number, to: number): BatchColumnSelectionSession | null { const selectableItems = items.filter((item) => item.type === "column" && item.batchSelectionMode && item.apply); if (selectableItems.length === 0) { - batchColumnSelectionSession = null; + clearBatchColumnSelectionSession(); return null; } @@ -3644,9 +3793,11 @@ function prepareBatchColumnSelectionSession(items: SqlCompletionItem[], document selectedKeys: new Set(), completionOptions: new Map(), }; + setBatchColumnSelectionExpandedRendering(true); return batchColumnSelectionSession; } + setBatchColumnSelectionExpandedRendering(true); batchColumnSelectionSession.candidates = candidates; batchColumnSelectionSession.qualifier = selectableItems[0]!.batchSelectionQualifier; const candidateKeys = new Set(candidates.map((candidate) => candidate.key)); @@ -3675,13 +3826,10 @@ function cacheBatchColumnSelectionOption(marker: QueryCompletionOption["dbxBatch function toggleBatchColumnSelection(view: EditorViewType, sessionKey: string, candidateKey: string) { const session = batchColumnSelectionSession; if (!session || session.key !== sessionKey || !session.candidates.some((candidate) => candidate.key === candidateKey)) return; - if (session.selectedKeys.has(candidateKey)) { - session.selectedKeys.delete(candidateKey); - } else { - session.selectedKeys.add(candidateKey); - } + setBatchColumnSelectionValue(sessionKey, candidateKey, !session.selectedKeys.has(candidateKey)); + updateBatchColumnSelectionActionLabel(view, sessionKey); // Reopen the list so the action row and all virtualized checkboxes reflect the new state. - window.setTimeout(() => codeMirrorStartCompletion?.(view), 0); + scheduleBatchColumnSelectionRefresh(view, sessionKey, candidateKey); } function toggleSelectedBatchColumnSelection(view: EditorViewType): boolean { @@ -3692,6 +3840,143 @@ function toggleSelectedBatchColumnSelection(view: EditorViewType): boolean { return true; } +function batchColumnSelectionMarkerAtPoint(clientX: number, clientY: number): { checkbox: HTMLInputElement; marker: BatchColumnSelectionCheckboxMarker } | null { + const target = document.elementFromPoint(clientX, clientY); + if (!(target instanceof HTMLElement)) return null; + const checkbox = target.closest("input.cm-batch-column-selection-checkbox") ?? target.closest("[role='option']")?.querySelector("input.cm-batch-column-selection-checkbox") ?? null; + if (!checkbox) return null; + const marker = batchColumnSelectionCheckboxMarkers.get(checkbox); + return marker ? { checkbox, marker } : null; +} + +function batchColumnSelectionScrollElement(checkbox: HTMLElement): HTMLElement | null { + let current = checkbox.parentElement; + while (current && current !== document.body) { + const style = window.getComputedStyle(current); + if (current.scrollHeight > current.clientHeight && /(auto|scroll|overlay)/.test(style.overflowY)) return current; + current = current.parentElement; + } + return null; +} + +function visibleBatchColumnSelectionCheckboxes(sessionKey: string): Array<{ checkbox: HTMLInputElement; marker: BatchColumnSelectionCheckboxMarker }> { + return Array.from(document.querySelectorAll("input.cm-batch-column-selection-checkbox")).flatMap((checkbox) => { + const marker = batchColumnSelectionCheckboxMarkers.get(checkbox); + return marker?.sessionKey === sessionKey ? [{ checkbox, marker }] : []; + }); +} + +function updateBatchColumnSelectionAtPoint(state: BatchColumnSelectionDragState, clientX: number, clientY: number) { + const hit = batchColumnSelectionMarkerAtPoint(clientX, clientY); + if (!hit || hit.marker.sessionKey !== state.sessionKey) return; + state.focusCandidateKey = hit.marker.candidateKey; + const session = batchColumnSelectionSession; + if (!session) return; + const visibleCheckboxes = visibleBatchColumnSelectionCheckboxes(state.sessionKey); + const anchorIndex = visibleCheckboxes.findIndex(({ marker }) => marker.candidateKey === state.anchorCandidateKey); + const focusIndex = visibleCheckboxes.findIndex(({ marker }) => marker.candidateKey === hit.marker.candidateKey); + if (anchorIndex < 0 || focusIndex < 0) return; + const nextSelectedKeys = new Set(state.baseSelectedKeys); + const rangeStart = Math.min(anchorIndex, focusIndex); + const rangeEnd = Math.max(anchorIndex, focusIndex); + for (let index = rangeStart; index <= rangeEnd; index++) { + const candidate = visibleCheckboxes[index]?.marker; + if (!candidate) continue; + if (state.selected) nextSelectedKeys.add(candidate.candidateKey); + else nextSelectedKeys.delete(candidate.candidateKey); + } + session.selectedKeys = nextSelectedKeys; + visibleCheckboxes.forEach(({ checkbox, marker }) => { + checkbox.checked = nextSelectedKeys.has(marker.candidateKey); + }); + updateBatchColumnSelectionActionLabel(state.view, state.sessionKey); +} + +const BATCH_COLUMN_SELECTION_AUTO_SCROLL_EDGE_PX = 36; +const BATCH_COLUMN_SELECTION_AUTO_SCROLL_MAX_PX = 20; + +function runBatchColumnSelectionAutoScroll() { + const state = batchColumnSelectionDragState; + if (!state) return; + state.autoScrollFrame = 0; + const scroller = state.scrollElement; + if (!scroller) return; + const rect = scroller.getBoundingClientRect(); + if (state.pointerClientX < rect.left || state.pointerClientX > rect.right) return; + let delta = 0; + if (state.pointerClientY < rect.top + BATCH_COLUMN_SELECTION_AUTO_SCROLL_EDGE_PX) { + delta = -BATCH_COLUMN_SELECTION_AUTO_SCROLL_MAX_PX * Math.min(1, (rect.top + BATCH_COLUMN_SELECTION_AUTO_SCROLL_EDGE_PX - state.pointerClientY) / BATCH_COLUMN_SELECTION_AUTO_SCROLL_EDGE_PX); + } else if (state.pointerClientY > rect.bottom - BATCH_COLUMN_SELECTION_AUTO_SCROLL_EDGE_PX) { + delta = BATCH_COLUMN_SELECTION_AUTO_SCROLL_MAX_PX * Math.min(1, (state.pointerClientY - (rect.bottom - BATCH_COLUMN_SELECTION_AUTO_SCROLL_EDGE_PX)) / BATCH_COLUMN_SELECTION_AUTO_SCROLL_EDGE_PX); + } + if (!delta) return; + const previousScrollTop = scroller.scrollTop; + scroller.scrollTop = Math.max(0, Math.min(scroller.scrollHeight - scroller.clientHeight, scroller.scrollTop + delta)); + if (scroller.scrollTop === previousScrollTop) return; + updateBatchColumnSelectionAtPoint(state, state.pointerClientX, state.pointerClientY); + state.autoScrollFrame = window.requestAnimationFrame(runBatchColumnSelectionAutoScroll); +} + +function scheduleBatchColumnSelectionAutoScroll(state: BatchColumnSelectionDragState) { + if (!state.scrollElement || state.autoScrollFrame) return; + state.autoScrollFrame = window.requestAnimationFrame(runBatchColumnSelectionAutoScroll); +} + +function onBatchColumnSelectionPointerMove(event: PointerEvent) { + const state = batchColumnSelectionDragState; + if (!state || event.pointerId !== state.pointerId) return; + if ((event.buttons & 1) === 0) { + finishBatchColumnSelectionDrag(); + return; + } + state.pointerClientX = event.clientX; + state.pointerClientY = event.clientY; + updateBatchColumnSelectionAtPoint(state, event.clientX, event.clientY); + scheduleBatchColumnSelectionAutoScroll(state); +} + +function onBatchColumnSelectionPointerUp(event: PointerEvent) { + const state = batchColumnSelectionDragState; + if (!state || event.pointerId !== state.pointerId) return; + finishBatchColumnSelectionDrag(); +} + +function onBatchColumnSelectionPointerCancel() { + finishBatchColumnSelectionDrag(); +} + +function startBatchColumnSelectionDrag(view: EditorViewType, marker: BatchColumnSelectionCheckboxMarker, checkbox: HTMLInputElement, event: PointerEvent) { + if (event.button !== 0 || !event.isPrimary) return; + finishBatchColumnSelectionDrag(false); + cancelBatchColumnSelectionRefresh(); + const session = batchColumnSelectionSession; + if (!session || session.key !== marker.sessionKey) return; + if (!session.candidates.some((candidate) => candidate.key === marker.candidateKey)) return; + const selected = !session.selectedKeys.has(marker.candidateKey); + const baseSelectedKeys = new Set(session.selectedKeys); + setBatchColumnSelectionValue(marker.sessionKey, marker.candidateKey, selected, checkbox); + batchColumnSelectionDragState = { + view, + sessionKey: marker.sessionKey, + pointerId: event.pointerId, + anchorCandidateKey: marker.candidateKey, + baseSelectedKeys, + selected, + focusCandidateKey: marker.candidateKey, + previousUserSelect: document.body.style.userSelect, + scrollElement: batchColumnSelectionScrollElement(checkbox), + pointerClientX: event.clientX, + pointerClientY: event.clientY, + autoScrollFrame: 0, + }; + updateBatchColumnSelectionActionLabel(view, marker.sessionKey); + document.body.style.userSelect = "none"; + window.addEventListener("pointermove", onBatchColumnSelectionPointerMove, true); + window.addEventListener("pointerup", onBatchColumnSelectionPointerUp, true); + window.addEventListener("pointercancel", onBatchColumnSelectionPointerCancel, true); + window.addEventListener("blur", onBatchColumnSelectionPointerCancel, true); +} + function renderBatchColumnSelectionCheckbox(completion: Completion, _state: import("@codemirror/state").EditorState, currentView: EditorViewType): Node | null { const marker = (completion as QueryCompletionOption).dbxBatchColumnSelection; const session = batchColumnSelectionSession; @@ -3703,12 +3988,17 @@ function renderBatchColumnSelectionCheckbox(completion: Completion, _state: impo checkbox.checked = session.selectedKeys.has(marker.candidateKey); checkbox.tabIndex = -1; checkbox.setAttribute("aria-label", completion.displayLabel ?? completion.label); + batchColumnSelectionCheckboxMarkers.set(checkbox, marker); + checkbox.addEventListener("pointerdown", (event) => { + event.preventDefault(); + event.stopPropagation(); + startBatchColumnSelectionDrag(currentView, marker, checkbox, event); + }); checkbox.addEventListener("mousedown", (event) => { // CodeMirror accepts the completion on a list-item mousedown. Keep this // interaction local to the checkbox so a field can be toggled repeatedly. event.preventDefault(); event.stopPropagation(); - toggleBatchColumnSelection(currentView, marker.sessionKey, marker.candidateKey); }); checkbox.addEventListener("click", (event) => { event.preventDefault(); @@ -3717,6 +4007,16 @@ function renderBatchColumnSelectionCheckbox(completion: Completion, _state: impo return checkbox; } +function renderBatchColumnSelectionActionMarker(completion: Completion): Node | null { + const action = (completion as QueryCompletionOption).dbxBatchColumnSelectionAction; + if (!action) return null; + const marker = document.createElement("span"); + marker.className = "cm-batch-column-selection-action-marker"; + marker.hidden = true; + batchColumnSelectionActionMarkers.set(marker, action.sessionKey); + return marker; +} + function applyBatchColumnSelection(view: EditorViewType, item: BatchColumnSelectionActionItem, from: number, to: number) { const session = batchColumnSelectionSession; if (!session || session.key !== item.sessionKey || session.document !== view.state.doc.toString()) return; @@ -3799,7 +4099,7 @@ function isTypedCompletionActivation(explicit: boolean) { } function markCompletionAccepted(item: QueryCompletionItem | BatchColumnSelectionActionItem) { - batchColumnSelectionSession = null; + clearBatchColumnSelectionSession(); const shouldContinueCompletion = shouldChainSqlCompletionAfterAccept(item) || (props.databaseType === "sqlserver" && item.type === "keyword" && item.label.toUpperCase() === "USE"); suppressNextSqlCompletionAutoStartUntil = shouldContinueCompletion ? 0 : Date.now() + 750; completionEpoch++; @@ -3910,6 +4210,7 @@ function completionOptionForItem(item: QueryCompletionItem | BatchColumnSelectio if (isBatchColumnSelectionAction(item)) { return { ...labelPresentation, + dbxBatchColumnSelectionAction: { sessionKey: item.sessionKey }, type: item.type, detail: item.detail, boost: item.boost, @@ -5206,7 +5507,24 @@ onMounted(async () => { }, { EditorState, EditorSelection, Compartment, Prec, RangeSet, StateEffect, StateField }, langSql, - { autocompletion, startCompletion, acceptCompletion, closeBrackets, closeBracketsKeymap, snippetCompletion, completionStatus, completionKeymap, insertCompletionText, nextSnippetField, closeCompletion, moveCompletionSelection, selectedCompletion, selectedCompletionIndex }, + { + autocompletion, + startCompletion, + acceptCompletion, + closeBrackets, + closeBracketsKeymap, + snippetCompletion, + completionStatus, + completionKeymap, + insertCompletionText, + nextSnippetField, + closeCompletion, + moveCompletionSelection, + selectedCompletion, + selectedCompletionIndex, + currentCompletions, + setSelectedCompletion, + }, { copyLineDown, copyLineUp, deleteLine, indentLess, indentMore, insertNewlineKeepIndent, moveLineDown, moveLineUp, redo, selectAll, undo, toggleLineComment, toggleBlockComment, history, defaultKeymap, historyKeymap }, { bracketMatching, foldGutter, indentOnInput, indentUnit, syntaxHighlighting, defaultHighlightStyle, foldKeymap, toggleFold, ensureSyntaxTree, highlightingFor, syntaxTree }, { searchKeymap }, @@ -5243,8 +5561,10 @@ onMounted(async () => { setSqlDiagnosticsEffect = StateEffect.define(); codeMirrorCompletionStatus = completionStatus; codeMirrorAcceptCompletion = acceptCompletion; + codeMirrorCurrentCompletions = currentCompletions; codeMirrorSelectedCompletion = selectedCompletion; codeMirrorSelectedCompletionIndex = selectedCompletionIndex; + codeMirrorSetSelectedCompletion = setSelectedCompletion; codeMirrorMoveCompletionSelection = moveCompletionSelection; codeMirrorSelectFirstCompletion = moveCompletionSelection(true); codeMirrorCloseCompletion = closeCompletion; @@ -5483,7 +5803,13 @@ onMounted(async () => { defaultKeymap: false, selectOnOpen: settingsStore.editorSettings.selectFirstCompletionOnOpen, compareCompletions: (a, b) => compareSqlCompletions(a, b, settingsStore.editorSettings.sortCompletionColumnsAlphabetically), - addToOptions: [{ position: 10, render: renderBatchColumnSelectionCheckbox }], + // Keep normal completion lists virtualized; batch-field selection needs every row in the DOM. + maxRenderedOptions: batchColumnSelectionExpandedRendering ? Number.MAX_SAFE_INTEGER : 100, + optionClass: (completion) => ((completion as QueryCompletionOption).dbxBatchColumnSelectionAction ? "cm-batch-column-selection-action" : ""), + addToOptions: [ + { position: 5, render: renderBatchColumnSelectionActionMarker }, + { position: 10, render: renderBatchColumnSelectionCheckbox }, + ], override: [async (context: CompletionContext) => provideSqlCompletions(context)], }); @@ -6156,6 +6482,7 @@ onMounted(async () => { }); view.value = new EditorView({ state, parent: editorElement }); + batchColumnSelectionTooltipParents.set(view.value, tooltipParent); postCompositionKeyGuardCleanup = postCompositionKeyGuard.attach(view.value.contentDOM); registerEditorScrollbarPointerGuard(view.value); view.value.scrollDOM.addEventListener("scroll", scheduleEditorViewportEmit, { @@ -6450,6 +6777,8 @@ watch( ); function pauseQueryEditorBackgroundWork() { + finishBatchColumnSelectionDrag(false); + cancelBatchColumnSelectionRefresh(); flushEditorViewport(); flushEditorSelection(); clearTableNavigationHover(); diff --git a/apps/desktop/src/components/editor/__tests__/QueryEditorContextMenu.spec.ts b/apps/desktop/src/components/editor/__tests__/QueryEditorContextMenu.spec.ts index 16ebf6a81b..9419dd2987 100644 --- a/apps/desktop/src/components/editor/__tests__/QueryEditorContextMenu.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/QueryEditorContextMenu.spec.ts @@ -127,4 +127,41 @@ describe("QueryEditor batch column selection", () => { expect(source).toContain('{ key: "ArrowDown", run: (view) => moveCompletion(view, true) }'); expect(source).toContain('{ key: "ArrowUp", run: (view) => moveCompletion(view, false) }'); }); + + it("marks the insertion action so the completion menu can keep it sticky", () => { + expect(source).toContain("dbxBatchColumnSelectionAction: { sessionKey: item.sessionKey }"); + expect(source).toContain('optionClass: (completion) => ((completion as QueryCompletionOption).dbxBatchColumnSelectionAction ? "cm-batch-column-selection-action" : "")'); + }); + + it("updates the insertion count while a mouse selection is still in progress", () => { + const dragUpdateStart = source.indexOf("function updateBatchColumnSelectionAtPoint"); + const dragUpdateEnd = source.indexOf("\n}\n\nconst BATCH_COLUMN_SELECTION_AUTO_SCROLL_EDGE_PX", dragUpdateStart); + + expect(source).toContain("function updateBatchColumnSelectionActionLabel"); + expect(source.slice(dragUpdateStart, dragUpdateEnd)).toContain("updateBatchColumnSelectionActionLabel(state.view, state.sessionKey);"); + expect(source).toContain("const batchColumnSelectionTooltipParents = new WeakMap();"); + expect(source).toContain("batchColumnSelectionTooltipParents.set(view.value, tooltipParent);"); + expect(source).toContain("const batchColumnSelectionActionMarkers = new WeakMap();"); + expect(source).toContain("batchColumnSelectionActionMarkers.get(marker) !== sessionKey"); + expect(source).toContain("renderBatchColumnSelectionActionMarker"); + }); + + it("cancels stale scroll restoration before a new drag can begin", () => { + const refreshStart = source.indexOf("function scheduleBatchColumnSelectionRefresh"); + const refreshEnd = source.indexOf("\n}\n\nfunction finishBatchColumnSelectionDrag", refreshStart); + const dragStart = source.indexOf("function startBatchColumnSelectionDrag"); + const dragEnd = source.indexOf("\n}\n\nfunction renderBatchColumnSelectionCheckbox", dragStart); + + expect(source).toContain("let batchColumnSelectionRefreshCleanup: (() => void) | null = null;"); + expect(source.slice(refreshStart, refreshEnd)).toContain("cancelBatchColumnSelectionRefresh();"); + expect(source.slice(dragStart, dragEnd)).toContain("cancelBatchColumnSelectionRefresh();"); + expect(source).toContain("if (restoreStartTimer) window.clearTimeout(restoreStartTimer);"); + }); + + it("only expands rendering for batch field selection", () => { + expect(source).toContain("function setBatchColumnSelectionExpandedRendering"); + expect(source).toContain("setBatchColumnSelectionExpandedRendering(true);"); + expect(source).toContain("setBatchColumnSelectionExpandedRendering(false);"); + expect(source).toContain("maxRenderedOptions: batchColumnSelectionExpandedRendering ? Number.MAX_SAFE_INTEGER : 100,"); + }); }); diff --git a/apps/desktop/src/lib/__tests__/editorThemes.spec.ts b/apps/desktop/src/lib/__tests__/editorThemes.spec.ts index c7fbe4344c..6e1ca9e37f 100644 --- a/apps/desktop/src/lib/__tests__/editorThemes.spec.ts +++ b/apps/desktop/src/lib/__tests__/editorThemes.spec.ts @@ -162,6 +162,16 @@ describe("SQL completion theme", () => { expect(rules[".cm-completionLabel"]).toMatchObject({ flex: "0 1 auto" }); expect(rules[".cm-completionDetail"]).toMatchObject({ flex: "1 1 0", minWidth: "0", textOverflow: "ellipsis" }); }); + + it("keeps the batch column insertion action fixed at the bottom of its menu", () => { + const rules = buildSqlCompletionThemeRules(); + + expect(rules[".cm-tooltip.cm-tooltip-autocomplete > ul > li.cm-batch-column-selection-action"]).toMatchObject({ + bottom: "0", + position: "sticky", + zIndex: "1", + }); + }); }); describe("editor gutters", () => { diff --git a/apps/desktop/src/lib/editor/editorThemes.ts b/apps/desktop/src/lib/editor/editorThemes.ts index 3eef1e11e8..80c587c6f2 100644 --- a/apps/desktop/src/lib/editor/editorThemes.ts +++ b/apps/desktop/src/lib/editor/editorThemes.ts @@ -973,6 +973,15 @@ export function buildSqlCompletionThemeRules(): CodeMirrorStyleSpec { color: "var(--popover-foreground) !important", outline: colorMixValue("1px solid var(--border)", "1px solid color-mix(in oklch, var(--primary) 22%, transparent)"), }, + ".cm-tooltip.cm-tooltip-autocomplete > ul > li.cm-batch-column-selection-action": { + background: "var(--popover)", + borderRadius: "0", + borderTop: colorMixValue("1px solid var(--border)", "1px solid color-mix(in oklch, var(--border) 82%, var(--foreground) 18%)"), + bottom: "0", + boxShadow: "0 -6px 12px rgb(0 0 0 / 0.06)", + position: "sticky", + zIndex: "1", + }, ".cm-completionIcon": { alignItems: "center", display: "inline-flex",