diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 8319b3b0f2..c8e4db963c 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -1128,6 +1128,10 @@ onUnmounted(() => { (tabId: string, viewport: { scrollTop: number; scrollLeft: number }) => queryStore.updateEditorViewport(tabId, viewport) " + @editor-selection-state-change=" + (tabId: string, selection: { anchor: number; head: number }) => + queryStore.updateEditorSelection(tabId, selection) + " @format-error="toast(t('toolbar.formatSqlFailed'))" @save-sql="void openSaveSqlDialog()" @reload=" diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 50c07729d8..046f6b66c5 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -105,6 +105,7 @@ const props = defineProps<{ readOnly?: boolean; forceWordWrap?: boolean; initialViewport?: { scrollTop: number; scrollLeft: number }; + initialSelection?: { anchor: number; head: number }; }>(); const emit = defineEmits<{ @@ -118,6 +119,7 @@ const emit = defineEmits<{ clickColumn: [columns: Array<{ name: string; table: string; schema?: string }>, error?: string | undefined]; closeColumnPanel: []; viewportChange: [viewport: { scrollTop: number; scrollLeft: number }]; + selectionStateChange: [selection: { anchor: number; head: number }]; }>(); const editorRef = ref(); @@ -125,6 +127,7 @@ const view = shallowRef(null); let viewportEmitFrame: number | null = null; let viewportRestoreFrame: number | null = null; let latestViewport: { scrollTop: number; scrollLeft: number } | undefined = props.initialViewport; +let latestSelection: { anchor: number; head: number } | undefined = props.initialSelection; const connectionStore = useConnectionStore(); const settingsStore = useSettingsStore(); const { isDark } = useTheme(); @@ -1728,6 +1731,7 @@ onMounted(async () => { const state = EditorState.create({ doc: props.modelValue, + selection: normalizedEditorSelection(props.initialSelection, props.modelValue.length), extensions: [ cmSearch({ top: true, @@ -1798,6 +1802,8 @@ onMounted(async () => { syncContextMenuState(update.view); emit("selectionChange", selectedSqlFromView(update.view)); emit("cursorChange", update.state.selection.main.head); + latestSelection = readEditorSelection(update.view); + if (editorIsActive) emitEditorSelection(latestSelection); } }), fontThemeComp.of( @@ -1816,6 +1822,11 @@ onMounted(async () => { drop(event, currentView) { return insertDroppedTableReference(currentView, event); }, + blur(_event, currentView) { + latestSelection = readEditorSelection(currentView); + if (editorIsActive) emitEditorSelection(latestSelection); + return false; + }, wheel(event) { if (!event.metaKey && !event.ctrlKey) return false; event.preventDefault(); @@ -2095,6 +2106,7 @@ watch( function pauseQueryEditorBackgroundWork() { flushEditorViewport(); + flushEditorSelection(); editorIsActive = false; semanticDiagnosticRunId++; if (semanticDiagnosticTimer) clearTimeout(semanticDiagnosticTimer); @@ -2107,6 +2119,8 @@ function resumeQueryEditorBackgroundWork() { editorIsActive = true; registerTableReferenceDropListener(); scheduleSemanticDiagnostics(); + restoreEditorSelection(); + restoreEditorFocus(); restoreEditorViewport(); } @@ -2136,12 +2150,55 @@ function readEditorViewport(currentView: EditorViewType) { }; } +function normalizedEditorSelection(selection: { anchor: number; head: number } | undefined, docLength: number) { + if (!selection) return undefined; + return { + anchor: Math.min(Math.max(0, selection.anchor), docLength), + head: Math.min(Math.max(0, selection.head), docLength), + }; +} + +function readEditorSelection(currentView: EditorViewType) { + const selection = currentView.state.selection.main; + return { + anchor: selection.anchor, + head: selection.head, + }; +} + +function emitEditorSelection(selection: { anchor: number; head: number }) { + emit("selectionStateChange", selection); +} + +function flushEditorSelection() { + if (view.value) latestSelection = readEditorSelection(view.value); + if (latestSelection) emitEditorSelection(latestSelection); +} + +function restoreEditorSelection() { + const selection = normalizedEditorSelection(props.initialSelection ?? latestSelection, props.modelValue.length); + if (!view.value || !selection) return; + view.value.dispatch({ selection }); +} + +function restoreEditorFocus() { + const focusEditorAcrossFrames = () => { + if (!view.value || view.value.hasFocus) return; + view.value.focus(); + }; + focusEditorAcrossFrames(); + nextTick(() => { + focusEditorAcrossFrames(); + requestAnimationFrame(focusEditorAcrossFrames); + }); +} + function emitEditorViewport(viewport: { scrollTop: number; scrollLeft: number }) { emit("viewportChange", viewport); } function scheduleEditorViewportEmit() { - if (!view.value) return; + if (!view.value || !editorIsActive) return; latestViewport = readEditorViewport(view.value); if (viewportEmitFrame !== null) return; viewportEmitFrame = requestAnimationFrame(() => { @@ -2198,7 +2255,7 @@ function openReplace(): boolean { } function scrollCursorIntoView() { - if (!view.value || !editorViewModule) return; + if (!view.value || !editorViewModule || !editorIsActive) return; const pos = view.value.state.selection.main.head; view.value.dispatch({ effects: editorViewModule.EditorView.scrollIntoView(pos, { y: "nearest" }), diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 5be2f820ee..c978f1c094 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -112,6 +112,7 @@ const emit = defineEmits<{ editorSelectionChange: [value: string]; editorCursorChange: [pos: number]; editorViewportChange: [tabId: string, viewport: { scrollTop: number; scrollLeft: number }]; + editorSelectionStateChange: [tabId: string, selection: { anchor: number; head: number }]; formatError: []; reload: [sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number]; paginate: [offset: number, limit: number, whereInput?: string, orderBy?: string]; @@ -447,10 +448,12 @@ defineExpose({ focusSearch, refreshData, handleModRTarget }); :format-request-id="formatSqlRequest?.tabId === activeTab.id ? formatSqlRequest.id : undefined" :execution-error="activeQueryError" :initial-viewport="activeTab.editorViewport" + :initial-selection="activeTab.editorSelection" @update:model-value="emit('editorUpdate', activeTab.id, $event)" @selection-change="emit('editorSelectionChange', $event)" @cursor-change="emit('editorCursorChange', $event)" @viewport-change="emit('editorViewportChange', activeTab.id, $event)" + @selection-state-change="emit('editorSelectionStateChange', activeTab.id, $event)" @format-error="emit('formatError')" @execute="emit('execute')" @save="emit('saveSql')" diff --git a/apps/desktop/src/lib/openTabsPersistence.ts b/apps/desktop/src/lib/openTabsPersistence.ts index 2739796f3b..01d95fed75 100644 --- a/apps/desktop/src/lib/openTabsPersistence.ts +++ b/apps/desktop/src/lib/openTabsPersistence.ts @@ -101,6 +101,7 @@ export function restoreOpenTabsState( isCancelling: false, queryExecutionStartedAt: undefined, editorViewport: undefined, + editorSelection: undefined, isExplaining: false, resultEvicted: mode === "data" ? undefined : tab.resultEvicted, resultCacheKey: mode === "data" ? undefined : tab.resultCacheKey, diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 3e96eefbea..77ad3ab30c 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -529,6 +529,12 @@ export const useQueryStore = defineStore("query", () => { tab.editorViewport = viewport; } + function updateEditorSelection(id: string, selection: { anchor: number; head: number }) { + const tab = tabs.value.find((t) => t.id === id); + if (!tab) return; + tab.editorSelection = selection; + } + function renameTab(id: string, title: string) { const trimmed = title.trim(); if (!trimmed) return false; @@ -1753,6 +1759,7 @@ export const useQueryStore = defineStore("query", () => { releaseDatabaseTabs, updateSql, updateEditorViewport, + updateEditorSelection, renameTab, openObjectBrowser, openUserAdmin, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index fefa2af6fb..95ff5085b5 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -400,6 +400,10 @@ export interface QueryTab { scrollTop: number; scrollLeft: number; }; + editorSelection?: { + anchor: number; + head: number; + }; executionId?: string; isExplaining?: boolean; explainExecutionId?: string;