fix(sidebar): show loading while global search is in flight
This commit is contained in:
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]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user