From d6fa7fd866f14f6d23e88bff2858c72e6e3e6817 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Fri, 7 Aug 2026 02:41:28 +0800 Subject: [PATCH] fix(sidebar): preserve table search focus and selection after refresh --- .../src/components/sidebar/ConnectionTree.vue | 88 +++++++++++++++---- 1 file changed, 70 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index d6e064b8d5..ba79243e98 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -117,8 +117,23 @@ const tableSearchTimers = new Map(); const tableSearchFocusRestoreTokens = new Map(); let tableSearchFocusRestoreTokenSeq = 0; let latestTableSearchInteractionParentId: string | null = null; +let latestTableSearchInteractionId = 0; +let tableSearchInteractionIdSeq = 0; let localTableSearchFocusPending = false; +type TableSearchSelection = { + start: number; + end: number; + direction: "forward" | "backward" | "none"; +}; + +type TableSearchFocusRestore = { + interactionId: number; + parentNodeId: string; + shouldRestoreFocus: boolean; + selection: TableSearchSelection | null; +}; + watch( searchQuery, (value) => { @@ -158,6 +173,7 @@ watch( store.setSidebarTableSearchQuery(parentNodeId, ""); } latestTableSearchInteractionParentId = null; + latestTableSearchInteractionId = 0; void Promise.all(parentNodeIds.map((parentNodeId) => store.refreshSidebarTableSearch(parentNodeId))).catch(() => {}); }, ); @@ -325,10 +341,10 @@ function clearSearchScopeFilter() { selectedSearchScopes.value = []; } -function scheduleSidebarTableSearchRefresh(parentNodeId: string, options?: { restoreFocus?: boolean }) { +function scheduleSidebarTableSearchRefresh(parentNodeId: string, options?: { focusRestore?: TableSearchFocusRestore }) { window.clearTimeout(tableSearchTimers.get(parentNodeId)); if (isTreeSearchFiltering.value) return; - const restoreToken = options?.restoreFocus ? ++tableSearchFocusRestoreTokenSeq : 0; + const restoreToken = options?.focusRestore?.shouldRestoreFocus ? ++tableSearchFocusRestoreTokenSeq : 0; if (restoreToken) { tableSearchFocusRestoreTokens.clear(); tableSearchFocusRestoreTokens.set(parentNodeId, restoreToken); @@ -339,30 +355,63 @@ function scheduleSidebarTableSearchRefresh(parentNodeId: string, options?: { res if (!restoreToken) return; if (tableSearchFocusRestoreTokens.get(parentNodeId) !== restoreToken) return; tableSearchFocusRestoreTokens.delete(parentNodeId); - if (latestTableSearchInteractionParentId !== parentNodeId) return; - if (document.activeElement === document.body || activeTableSearchParentId() === parentNodeId) { - focusTableSearchInput(parentNodeId); - } + const focusRestore = options?.focusRestore; + if (!focusRestore || !isCurrentTableSearchInteraction(focusRestore)) return; + restoreTableSearchInput(focusRestore); }); }, 250); tableSearchTimers.set(parentNodeId, timer); } -function activeTableSearchParentId(): string | null { +function captureTableSearchFocus(parentNodeId: string): TableSearchFocusRestore { + const interactionId = ++tableSearchInteractionIdSeq; const active = document.activeElement; - if (!(active instanceof HTMLElement)) return null; - return active.dataset.sidebarTableSearchParentId || null; + const isActiveSearchInput = active instanceof HTMLInputElement && active.dataset.sidebarTableSearchParentId === parentNodeId; + + return { + interactionId, + parentNodeId, + shouldRestoreFocus: isActiveSearchInput, + selection: isActiveSearchInput + ? { + start: active.selectionStart ?? active.value.length, + end: active.selectionEnd ?? active.value.length, + direction: active.selectionDirection ?? "none", + } + : null, + }; } -function focusTableSearchInput(parentNodeId: string) { +function isCurrentTableSearchInteraction(focusRestore: TableSearchFocusRestore): boolean { + return latestTableSearchInteractionParentId === focusRestore.parentNodeId && latestTableSearchInteractionId === focusRestore.interactionId; +} + +function restoreTableSearchInput(focusRestore: TableSearchFocusRestore) { void nextTick(() => { + if (!isCurrentTableSearchInteraction(focusRestore) || !focusRestore.shouldRestoreFocus) return; const root = rootRef.value; if (!root) return; - const input = Array.from(root.querySelectorAll("[data-sidebar-table-search-parent-id]")).find((item) => item.dataset.sidebarTableSearchParentId === parentNodeId); + const input = Array.from(root.querySelectorAll("[data-sidebar-table-search-parent-id]")).find((item) => item.dataset.sidebarTableSearchParentId === focusRestore.parentNodeId); if (!input) return; + + // Keep the browser's current selection when the tree update preserved the + // input element. Only restore focus and selection if the async update + // actually displaced focus or recreated the input. + if (document.activeElement === input) return; + if (document.activeElement !== document.body) return; + input.focus({ preventScroll: true }); - const end = input.value.length; - input.setSelectionRange(end, end); + const selection = focusRestore.selection; + if (!selection) { + const end = input.value.length; + input.setSelectionRange(end, end); + return; + } + + const valueLength = input.value.length; + const start = Math.min(selection.start, valueLength); + const end = Math.min(selection.end, valueLength); + input.setSelectionRange(start, end, selection.direction); }); } @@ -392,12 +441,12 @@ function filterLocallySearchedTables(nodes: TreeNode[]): TreeNode[] { }); } -async function loadLocalTableSearchResults(parentNodeId: string, refresh = false) { +async function loadLocalTableSearchResults(parentNodeId: string, refresh = false, focusRestore?: TableSearchFocusRestore) { try { const entries = refresh ? await store.refreshSidebarTableSearchIndex(parentNodeId) : await store.loadSidebarTableSearchIndex(parentNodeId); localTableSearchResults.value = { ...localTableSearchResults.value, [parentNodeId]: entries }; } finally { - if (latestTableSearchInteractionParentId === parentNodeId) focusTableSearchInput(parentNodeId); + if (focusRestore) restoreTableSearchInput(focusRestore); } } @@ -843,16 +892,18 @@ provide(sidebarTreeContextKey, { getVisibleNodes: () => selectableVisibleNodes.value, getVisibleNodeIndex: (id: string) => selectableVisibleNodeIndexById.value.get(id) ?? -1, setTableSearchQuery: (parentNodeId, query, local) => { + const focusRestore = captureTableSearchFocus(parentNodeId); latestTableSearchInteractionParentId = parentNodeId; + latestTableSearchInteractionId = focusRestore.interactionId; store.setSidebarTableSearchQuery(parentNodeId, query); if (local) { localTableSearchFocusPending = true; void nextTick(() => { - focusTableSearchInput(parentNodeId); + restoreTableSearchInput(focusRestore); localTableSearchFocusPending = false; }); - void loadLocalTableSearchResults(parentNodeId); - } else scheduleSidebarTableSearchRefresh(parentNodeId, { restoreFocus: true }); + void loadLocalTableSearchResults(parentNodeId, false, focusRestore); + } else scheduleSidebarTableSearchRefresh(parentNodeId, { focusRestore }); }, refreshTableSearchIndex: (parentNodeId) => void loadLocalTableSearchResults(parentNodeId, true), registerPasteHandler: pasteHandlerRegistry.register, @@ -1668,6 +1719,7 @@ onUnmounted(() => { tableSearchTimers.clear(); tableSearchFocusRestoreTokens.clear(); latestTableSearchInteractionParentId = null; + latestTableSearchInteractionId = 0; stopSidebarScrollbarDrag(); stopSidebarHorizontalScrollbarDrag(); sidebarScrollbarResizeObserver?.disconnect();