fix(sidebar): show loading while global search is in flight

This commit is contained in:
Melody Ruch authored and GitHub committed 2026-08-15 18:56:58 +08:00
1 parent bdf5f6a87c
commit c573c249ed
6 files changed
+123 -8

No files matched your search

@@ -22,6 +22,7 @@ import {
import { createSidebarLabelMatcher, matchSidebarLabel } from "@/lib/sidebar/sidebarSearch";
import { collectSidebarRegexIndexScopes, resolveSidebarRemoteSearchQuery, resolveSidebarSearchDispatchMode } from "@/lib/sidebar/sidebarRegexSearchIndex";
import { createSidebarSearchExpansionState } from "@/lib/sidebar/sidebarSearchExpansionState";
import { createSidebarSearchLoadingTracker } from "@/lib/sidebar/sidebarSearchLoadingTracker";
import { buildTableTreeNodes } from "@/lib/table/tableTree";
import { isCancelSearchShortcut, isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut, isViewTableDdlShortcut } from "@/lib/editor/keyboardShortcuts";
import { sidebarNodeSupportsDdlView } from "@/lib/sidebar/sidebarTreeDdlShortcut";
@@ -78,6 +79,8 @@ const { toast } = useToast();
const searchQuery = ref("");
const deferredSearchQuery = ref("");
const regexMode = ref(false);
const sidebarSearchLoadingTracker = createSidebarSearchLoadingTracker();
const isSidebarSearchLoading = ref(false);
const showConnectedConnectionsOnly = ref(false);
const isDisconnectingAllActiveConnections = ref(false);
const searchInputRef = ref<HTMLInputElement>();
@@ -262,14 +265,20 @@ watch([deferredSearchQuery, regexMode], ([newQuery, isRegexMode], [oldQuery, was
// Regex search is a read-only projection over live nodes and the local
// table index. It must never trigger ensureConnected/listTables.
const restoreTasks = restoreTrackedSearchTargets();
void Promise.all([loadRegexTableSearchIndexes(), ...restoreTasks])
const searchGeneration = sidebarSearchLoadingTracker.begin();
isSidebarSearchLoading.value = true;
void Promise.allSettled([loadRegexTableSearchIndexes(), ...restoreTasks])
.then(() => {
if (restoreTasks.length > 0) refreshActiveSidebarTableSearches();
})
.catch(() => {});
.finally(() => {
if (sidebarSearchLoadingTracker.end(searchGeneration)) isSidebarSearchLoading.value = false;
});
return;
}
if (dispatchMode === "none") {
sidebarSearchLoadingTracker.cancel();
isSidebarSearchLoading.value = false;
const restoreTasks = restoreTrackedSearchTargets();
if (restoreTasks.length > 0) {
void Promise.all(restoreTasks)
@@ -286,11 +295,21 @@ watch([deferredSearchQuery, regexMode], ([newQuery, isRegexMode], [oldQuery, was
if (!newQuery && oldQuery) {
searchExpansionState.clear();
}
Promise.all(tasks)
let searchGeneration = -1;
if (newQuery && tasks.length > 0) {
searchGeneration = sidebarSearchLoadingTracker.begin();
isSidebarSearchLoading.value = true;
} else {
sidebarSearchLoadingTracker.cancel();
isSidebarSearchLoading.value = false;
}
void Promise.allSettled(tasks)
.then(() => {
if (!newQuery && oldQuery) refreshActiveSidebarTableSearches();
})
.catch(() => {});
.finally(() => {
if (searchGeneration >= 0 && sidebarSearchLoadingTracker.end(searchGeneration)) isSidebarSearchLoading.value = false;
});
});
const searchableObjectGroupTypes = new Set<TreeNodeType>(["group-tables", "group-dolt-system-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-triggers", "group-sequences", "group-synonyms", "group-packages", "group-types"]);
@@ -2023,7 +2042,8 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
<div class="connection-tree-search sticky top-0 z-10 bg-background px-2 py-1">
<div class="relative flex items-center gap-1">
<div class="relative flex-1">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Loader2 v-if="isSidebarSearchLoading" class="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 animate-spin text-muted-foreground" />
<Search v-else class="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<input
ref="searchInputRef"
v-model="searchQuery"
@@ -134,6 +134,11 @@ export class MetadataLoadCoordinator {
return this.inFlight.has(key);
}
inFlightPromise<T>(scope: MetadataScopeInput | string): Promise<T> | undefined {
const key = typeof scope === "string" ? scope : metadataScopeKey(scope);
return this.inFlight.get(key)?.promise as Promise<T> | undefined;
}
clear(scope?: MetadataScopeInput | string) {
if (scope === undefined) {
this.inFlight.clear();
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { createSidebarSearchLoadingTracker } from "@/lib/sidebar/sidebarSearchLoadingTracker";
describe("sidebar search loading tracker", () => {
it("is idle until a search dispatch begins", () => {
const tracker = createSidebarSearchLoadingTracker();
expect(tracker.isLoading).toBe(false);
});
it("reports loading while a dispatch is in flight and clears on matching end", () => {
const tracker = createSidebarSearchLoadingTracker();
const generation = tracker.begin();
expect(tracker.isLoading).toBe(true);
expect(tracker.end(generation)).toBe(true);
expect(tracker.isLoading).toBe(false);
});
it("ignores a stale end() from a superseded dispatch (fast typing race)", () => {
// This is the exact race in ConnectionTree.vue's search watcher: each keystroke
// (after debounce) re-dispatches expansion tasks; a slow earlier dispatch must not
// clear the spinner set by a newer, still-in-flight dispatch.
const tracker = createSidebarSearchLoadingTracker();
const firstGeneration = tracker.begin();
const secondGeneration = tracker.begin();
expect(tracker.isLoading).toBe(true);
expect(tracker.end(firstGeneration)).toBe(false);
expect(tracker.isLoading).toBe(true);
expect(tracker.end(secondGeneration)).toBe(true);
expect(tracker.isLoading).toBe(false);
});
it("cancel() immediately clears loading and invalidates any pending end()", () => {
const tracker = createSidebarSearchLoadingTracker();
const generation = tracker.begin();
tracker.cancel();
expect(tracker.isLoading).toBe(false);
expect(tracker.end(generation)).toBe(false);
expect(tracker.isLoading).toBe(false);
});
});
@@ -0,0 +1,34 @@
export interface SidebarSearchLoadingTracker {
readonly isLoading: boolean;
/** Marks a new search dispatch as in flight; returns a generation token for `end`. */
begin(): number;
/** Clears the loading state if `generation` is still the latest dispatch; returns whether it cleared. */
end(generation: number): boolean;
/** Immediately clears the loading state and invalidates any in-flight `end` calls (e.g. query cleared). */
cancel(): void;
}
export function createSidebarSearchLoadingTracker(): SidebarSearchLoadingTracker {
let generation = 0;
let loading = false;
return {
get isLoading() {
return loading;
},
begin() {
generation += 1;
loading = true;
return generation;
},
end(calledGeneration: number) {
if (calledGeneration !== generation) return false;
loading = false;
return true;
},
cancel() {
generation += 1;
loading = false;
},
};
}
@@ -518,8 +518,16 @@ describe("connectionStore metadata loading", () => {
store.treeNodes = [node];
const normalLoad = store.loadDatabases(connection.id);
const searchLoad = store.loadConnectedConnectionRootForSidebarSearch(connection.id);
await listStarted;
const searchLoad = store.loadConnectedConnectionRootForSidebarSearch(connection.id);
let searchLoadSettled = false;
void searchLoad.finally(() => {
searchLoadSettled = true;
});
await Promise.resolve();
expect(searchLoadSettled).toBe(false);
resolveDatabases([{ name: "dajia", comment: null }]);
await Promise.all([normalLoad, searchLoad]);
+7 -2
View File
@@ -3755,9 +3755,14 @@ export const useConnectionStore = defineStore("connection", () => {
const config = getConfig(connectionId);
if (!config || ["redis", "etcd", "zookeeper", "consul", "mongodb", "elasticsearch", "easysearch", "meilisearch", "milvus", "qdrant", "weaviate", "chromadb", "mq", "nacos"].includes(config.db_type)) return;
const node = findConnectionNode(connectionId);
if (!node || node.type !== "connection" || node.isLoading || hasConnectionMetadataChildren(node.children)) return;
if (!node || node.type !== "connection" || hasConnectionMetadataChildren(node.children)) return;
const scope = { kind: "connection-databases" as const, connectionId, driverProfile: metadataDriverProfile(config) };
if (metadataLoadCoordinator.has(scope)) return;
const inFlight = metadataLoadCoordinator.inFlightPromise<void>(scope);
if (inFlight) {
await inFlight;
return;
}
if (node.isLoading) return;
const wasExpanded = !!node.isExpanded;
const load = beginTreeNodeLoad(node);