feat(desktop): add Driver Store category navigation

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-07-28 21:56:03 +08:00
1 parent c7e7b7c9d9
commit 06ed8dc5f6
12 files changed
+802 -105

No files matched your search

@@ -32,6 +32,8 @@ import {
import { PRESTOSQL_DRIVER_DB_TYPE, prestoSqlBuiltinDriverRow, prestoSqlMavenBundle } from "@/lib/database/prestoSqlBuiltinDriver";
import type { DriverStoreFocus } from "@/lib/connection/agentDriverInstallHint";
import { isOfflineDriverPackage, webDriverImportAccept } from "@/lib/driverStore/driverImportSelection";
import { DRIVER_CATEGORIES, getCategoryForAgentDriver, assertAgentDriverCategoriesComplete } from "@/lib/connection/driver-category-definitions";
import { selectUpdatableDrivers, selectStableDrivers, hasAnyUpdatableDriverMatching } from "@/lib/connection/driverListFilter";
const { t } = useI18n();
const { toast } = useToast();
@@ -174,6 +176,7 @@ async function applyDriverStoreDir(kind: DriverStoreDirKind, newDir: string | nu
const drivers = ref<AgentDriverInfo[]>([]);
const agentDriverSearch = ref("");
const selectedDriverCategory = ref<string>("all");
const installing = ref<string | null>(null);
const upgradingAll = ref(false);
const upgradingCompletedCount = ref(0);
@@ -374,6 +377,14 @@ function removeQueuedDriverInstall(dbType: string) {
async function refreshAgents() {
updateAgentDrivers(await api.listInstalledAgents());
void loadDriverStoreUsage();
// DEV: validate category coverage
if (import.meta.env.DEV) {
try {
assertAgentDriverCategoriesComplete(drivers.value.map((d) => d.db_type));
} catch (e) {
console.warn("[DriverStore] Agent driver category mapping incomplete:", e);
}
}
}
async function forceRefresh() {
@@ -756,39 +767,86 @@ type JdbcDriverListItem =
bundle: JdbcMavenBundleInfo;
};
const filteredAgentDrivers = computed(() => {
const query = agentDriverSearch.value.trim().toLowerCase();
if (!query) return builtinDriverRows.value;
return builtinDriverRows.value.filter((driver) => [driver.label, driver.db_type, driver.version, driver.installed_version, driverRequiresJavaRuntime(driver) ? driver.jre : ""].filter(Boolean).join(" ").toLowerCase().includes(query));
// Resolved category list with i18n
const driverCategories = computed(() =>
DRIVER_CATEGORIES.map((cat) => ({
key: cat.key,
title: t(cat.titleKey),
})),
);
const driverSearchQuery = computed(() => agentDriverSearch.value.trim().toLowerCase());
const isDriverSearchActive = computed(() => !!driverSearchQuery.value);
// Category label helper (for search results badges) — defined before searchedDrivers
function driverCategoryLabel(dbType: string): string {
const catKey = getCategoryForAgentDriver(dbType);
if (catKey === "all") return t("driverStore.driverCategoryAll");
const catDef = DRIVER_CATEGORIES.find((c) => c.key === catKey);
return catDef ? t(catDef.titleKey) : t("driverStore.driverCategoryAll");
}
// Search only stable drivers — updatable drivers already appear in the global update section.
const stableBuiltinDrivers = computed(() => selectStableDrivers(builtinDriverRows.value));
// Filter stable drivers through search (or pass all through if no search)
const searchedDrivers = computed(() => {
const query = driverSearchQuery.value;
if (!query) return stableBuiltinDrivers.value;
return stableBuiltinDrivers.value.filter((driver) => [driver.label, driver.db_type, driver.version, driver.installed_version, driverRequiresJavaRuntime(driver) ? driver.jre : "", driverCategoryLabel(driver.db_type)].filter(Boolean).join(" ").toLowerCase().includes(query));
});
/** filteredAgentDrivers reordered: updatable drivers first, then the rest (installed + not-installed). */
const orderedFilteredDrivers = computed(() => {
const updatable: AgentDriverInfo[] = [];
const stable: AgentDriverInfo[] = [];
for (const driver of filteredAgentDrivers.value) {
if (driver.update_available) {
updatable.push(driver);
} else {
stable.push(driver);
}
// When search is active: group by category
const searchedDriversByCategory = computed(() => {
if (!isDriverSearchActive.value) return [];
const grouped = new Map<string, AgentDriverInfo[]>();
for (const driver of searchedDrivers.value) {
const cat = getCategoryForAgentDriver(driver.db_type);
if (!grouped.has(cat)) grouped.set(cat, []);
grouped.get(cat)!.push(driver);
}
return [...updatable, ...stable];
return [...grouped.entries()].map(([catKey, drivers]) => {
const catDef = DRIVER_CATEGORIES.find((c) => c.key === catKey);
return {
key: catKey,
title: catKey === "all" ? t("driverStore.driverCategoryAll") : catDef ? t(catDef.titleKey) : t("driverStore.driverCategoryAll"),
drivers,
};
});
});
/** Number of updatable drivers in the currently filtered list. */
const filteredUpdatableCount = computed(() => {
let count = 0;
for (const driver of orderedFilteredDrivers.value) {
if (driver.update_available) count++;
else break;
}
return count;
// When search is NOT active: filter by selected category
const categoryFilteredDrivers = computed(() => {
if (isDriverSearchActive.value || selectedDriverCategory.value === "all") return searchedDrivers.value;
return searchedDrivers.value.filter((driver) => getCategoryForAgentDriver(driver.db_type) === selectedDriverCategory.value);
});
/** Filtered driver rows split: updatable-only and stable-only (no <template> needed in markup). */
const filteredUpdatableDrivers = computed(() => orderedFilteredDrivers.value.slice(0, filteredUpdatableCount.value));
const filteredStableDrivers = computed(() => orderedFilteredDrivers.value.slice(filteredUpdatableCount.value));
// Global updatable drivers — always shown above category navigation, regardless of filter.
const globalUpdatableDrivers = computed(() => selectUpdatableDrivers(builtinDriverRows.value));
// Whether any global updatable driver matches the current search query or
// selected category. Used to decide whether the empty‑state message should be
// suppressed: the global update section always renders *all* updatable drivers
// (unfiltered), so the empty‑state must only be hidden when at least one of
// those drivers is actually relevant to the user's current view.
const hasMatchingUpdatableDrivers = computed(() =>
hasAnyUpdatableDriverMatching(globalUpdatableDrivers.value, {
searchQuery: driverSearchQuery.value,
selectedCategory: selectedDriverCategory.value,
driverMatchesSearch: (driver, query) => [driver.label, driver.db_type, driver.version, driver.installed_version, driverRequiresJavaRuntime(driver) ? driver.jre : "", driverCategoryLabel(driver.db_type)].filter(Boolean).join(" ").toLowerCase().includes(query),
driverCategory: (driver) => getCategoryForAgentDriver(driver.db_type),
}),
);
// Category-filtered drivers, excluding those already shown in the global update banner.
const categoryStableDrivers = computed(() => selectStableDrivers(categoryFilteredDrivers.value));
// Category selection handler
function selectDriverCategory(key: string) {
selectedDriverCategory.value = key;
// Clear search when switching categories
agentDriverSearch.value = "";
}
const highlightedFocusKey = ref<string | null>(null);
let focusHighlightTimer: ReturnType<typeof setTimeout> | undefined;
@@ -806,6 +864,7 @@ watch(
// Wait until the requested driver row is loaded before scrolling to it.
if (!focus.driver || !builtinDriverRows.value.some((driver) => driver.db_type === focus.driver)) return;
agentDriverSearch.value = "";
selectedDriverCategory.value = "all";
}
const key = focusElementKey(focus);
highlightedFocusKey.value = key;
@@ -1343,17 +1402,11 @@ watch(driverStoreTab, (tab) => {
<Search class="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input v-model="agentDriverSearch" class="h-8 pl-8 text-xs" :placeholder="t('driverStore.searchDrivers')" />
</div>
<div v-if="drivers.length === 0" class="py-12 text-center text-sm text-muted-foreground">
{{ t("common.loading") }}
</div>
<div v-else-if="filteredAgentDrivers.length === 0" class="py-12 text-center text-sm text-muted-foreground">
{{ t("driverStore.noMatchingDrivers") }}
</div>
<div v-else class="driver-store-agent-list rounded-lg border divide-y">
<!-- Updates Available header -->
<div v-if="filteredUpdatableDrivers.length > 0" class="flex items-center justify-between px-4 py-2.5 bg-amber-500/10">
<!-- Global update section — always above category navigation -->
<div v-if="globalUpdatableDrivers.length > 0" class="rounded-lg border divide-y">
<div class="flex items-center justify-between bg-amber-500/10 px-4 py-2.5">
<div class="min-w-0">
<div class="text-sm font-semibold">{{ t("driverStore.updatesAvailableTitle") }} ({{ filteredUpdatableCount }})</div>
<div class="text-sm font-semibold">{{ t("driverStore.updatesAvailableTitle") }} ({{ globalUpdatableDrivers.length }})</div>
<p class="text-xs text-muted-foreground">{{ t("driverStore.updatesAvailableDescription") }}</p>
</div>
<Button size="sm" class="h-7 rounded-md text-xs shrink-0 ml-3" :disabled="installing !== null || upgradingAll || importingZip" @click="upgradeAll">
@@ -1363,7 +1416,7 @@ watch(driverStoreTab, (tab) => {
</Button>
</div>
<div
v-for="driver in filteredUpdatableDrivers"
v-for="driver in globalUpdatableDrivers"
:key="driver.db_type"
:data-driver-store-focus="`driver:${driver.db_type}`"
class="driver-store-agent-row flex items-center gap-3 px-4 py-2 transition hover:bg-muted/30"
@@ -1431,77 +1484,199 @@ watch(driverStoreTab, (tab) => {
</Button>
</div>
</div>
<!-- Section divider between updatable and stable -->
<div v-if="filteredUpdatableDrivers.length > 0 && filteredStableDrivers.length > 0" class="px-4 py-1.5 bg-muted/20 text-xs font-medium text-muted-foreground">
{{ t("driverStore.allDriversTitle") }}
</div>
<div
v-for="driver in filteredStableDrivers"
:key="driver.db_type"
:data-driver-store-focus="`driver:${driver.db_type}`"
class="driver-store-agent-row flex items-center gap-3 px-4 py-2 transition hover:bg-muted/30"
:class="{ 'driver-store-focus-highlight': highlightedFocusKey === `driver:${driver.db_type}` }"
>
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-muted/60 shrink-0">
<DatabaseIcon :db-type="driver.db_type" class="h-4 w-4" />
</span>
<div class="driver-store-agent-name min-w-0 flex-1">
<div class="text-sm font-medium">{{ driver.label }}</div>
</div>
<!-- Category nav + driver list container -->
<div class="min-h-0 flex flex-1 flex-col gap-3 overflow-hidden sm:flex-row sm:gap-0">
<!-- Category navigation sidebar -->
<nav v-if="!isDriverSearchActive" data-driver-category-nav class="flex shrink-0 gap-1 overflow-x-auto border-b px-0.5 pt-0.5 pb-2 sm:w-40 sm:flex-col sm:overflow-y-auto sm:border-b-0 sm:border-r sm:py-0.5 sm:pr-3.5" :aria-label="t('driverStore.driverCategoryLabel')">
<button
type="button"
class="shrink-0 whitespace-nowrap rounded-[4px] px-3 py-2 text-left text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-full"
:class="selectedDriverCategory === 'all' ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted/70'"
:aria-current="selectedDriverCategory === 'all' ? 'page' : undefined"
@click="selectDriverCategory('all')"
>
{{ t("driverStore.driverCategoryAll") }}
</button>
<button
v-for="cat in driverCategories"
:key="cat.key"
type="button"
class="shrink-0 whitespace-nowrap rounded-[4px] px-3 py-2 text-left text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-full"
:class="selectedDriverCategory === cat.key ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted/70'"
:aria-current="selectedDriverCategory === cat.key ? 'page' : undefined"
@click="selectDriverCategory(cat.key)"
>
{{ cat.title }}
</button>
</nav>
<!-- Driver list area -->
<div class="min-w-0 flex-1 overflow-y-auto sm:pl-4">
<!-- Empty: still loading -->
<div v-if="drivers.length === 0" class="py-12 text-center text-sm text-muted-foreground">
{{ t("common.loading") }}
</div>
<div class="driver-store-agent-meta flex shrink-0 items-center gap-1.5">
<span v-if="driverRequiresJavaRuntime(driver) && driver.jre" class="rounded-full px-2 py-0.5 text-[11px]" :class="driver.jre !== '21' ? 'bg-blue-500/10 text-blue-600' : 'bg-muted text-muted-foreground'">JRE {{ driver.jre }}</span>
<span v-if="driver.installed" class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">v{{ driver.installed_version }}</span>
<span v-if="driver.installed && driver.update_available" class="rounded-full bg-amber-500/15 px-2 py-0.5 text-[11px] text-amber-600">→ v{{ driver.version }}</span>
<span v-if="!driver.installed && driver.version" class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">v{{ driver.version }}</span>
<span v-if="formatSize(driver.size)" class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">{{ formatSize(driver.size) }}</span>
<!-- Empty: no search results (suppressed when updatable drivers match the search) -->
<div v-else-if="isDriverSearchActive && searchedDrivers.length === 0 && !hasMatchingUpdatableDrivers" class="py-12 text-center text-sm text-muted-foreground">
{{ t("driverStore.noMatchingDrivers") }}
</div>
<div class="driver-store-agent-actions flex shrink-0 items-center gap-2">
<Button
v-if="!driver.installed && isDriverQueued(driver.db_type)"
size="sm"
variant="outline"
class="h-7 rounded-md border-green-500/30 bg-green-500/10 text-xs text-green-700 hover:bg-green-500/15"
:disabled="upgradingAll || importingZip"
@click="removeQueuedDriverInstall(driver.db_type)"
<!-- Empty: no drivers in selected category (suppressed when updatable drivers match the category) -->
<div v-else-if="!isDriverSearchActive && categoryFilteredDrivers.length === 0 && !hasMatchingUpdatableDrivers" class="py-12 text-center text-sm text-muted-foreground">
{{ t("driverStore.noMatchingDrivers") }}
</div>
<!-- Search results: cross-category view -->
<div v-else-if="isDriverSearchActive && searchedDriversByCategory.length > 0" class="space-y-4">
<div v-for="group in searchedDriversByCategory" :key="group.key" class="space-y-2">
<h3 class="px-4 text-sm font-medium text-muted-foreground">{{ group.title }}</h3>
<div class="rounded-lg border divide-y">
<div
v-for="driver in group.drivers"
:key="driver.db_type"
:data-driver-store-focus="`driver:${driver.db_type}`"
class="driver-store-agent-row flex items-center gap-3 px-4 py-2 transition hover:bg-muted/30"
:class="{ 'driver-store-focus-highlight': highlightedFocusKey === `driver:${driver.db_type}` }"
>
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-muted/60 shrink-0">
<DatabaseIcon :db-type="driver.db_type" class="h-4 w-4" />
</span>
<div class="driver-store-agent-name min-w-0 flex-1">
<div class="text-sm font-medium">{{ driver.label }}</div>
</div>
<span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">{{ driverCategoryLabel(driver.db_type) }}</span>
<div class="driver-store-agent-meta flex shrink-0 items-center gap-1.5">
<span v-if="driverRequiresJavaRuntime(driver) && driver.jre" class="rounded-full px-2 py-0.5 text-[11px]" :class="driver.jre !== '21' ? 'bg-blue-500/10 text-blue-600' : 'bg-muted text-muted-foreground'">JRE {{ driver.jre }}</span>
<span v-if="driver.installed" class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">v{{ driver.installed_version }}</span>
<span v-if="driver.installed && driver.update_available" class="rounded-full bg-amber-500/15 px-2 py-0.5 text-[11px] text-amber-600">→ v{{ driver.version }}</span>
<span v-if="!driver.installed && driver.version" class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">v{{ driver.version }}</span>
<span v-if="formatSize(driver.size)" class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">{{ formatSize(driver.size) }}</span>
</div>
<div class="driver-store-agent-actions flex shrink-0 items-center gap-2">
<Button
v-if="!driver.installed && isDriverQueued(driver.db_type)"
size="sm"
variant="outline"
class="h-7 rounded-md border-green-500/30 bg-green-500/10 text-xs text-green-700 hover:bg-green-500/15"
:disabled="upgradingAll || importingZip"
@click="removeQueuedDriverInstall(driver.db_type)"
>
<Clock3 class="h-3 w-3 mr-1" />
{{ t("driverStore.queued") }}
</Button>
<DriverInstallProgressCircle v-else-if="!driver.installed && isDriverProgressActive(driver.db_type)" :percent="getAgentProgressPercent(driver.db_type)" :title="getAgentProgressTitle(driver.db_type, t('driverStore.installing'))" />
<Button v-else-if="!driver.installed" size="sm" class="h-7 rounded-md text-xs" :disabled="upgradingAll || importingZip" @click="installDriver(driver.db_type)">
<Download class="h-3 w-3 mr-1" />
{{ t("driverStore.install") }}
</Button>
<Button
v-if="!driver.installed && !isPrestoSqlBuiltinDriver(driver.db_type) && !isDriverProgressActive(driver.db_type) && !isDriverQueued(driver.db_type)"
size="sm"
variant="ghost"
class="h-7 w-7 rounded-md text-xs text-muted-foreground"
:title="t('driverStore.importLocalJar')"
:disabled="upgradingAll || installing !== null || importingZip"
@click="importDriverFile(driver)"
>
<FileUp class="h-3.5 w-3.5" />
</Button>
<Check v-if="driver.installed && !(driver.update_available && isDriverProgressActive(driver.db_type))" class="h-4 w-4 text-green-600" />
<Button
v-if="driver.installed && driver.update_available && isDriverQueued(driver.db_type)"
size="sm"
variant="outline"
class="h-7 rounded-md border-green-500/30 bg-green-500/10 text-xs text-green-700 hover:bg-green-500/15"
:disabled="upgradingAll || importingZip"
@click="removeQueuedDriverInstall(driver.db_type)"
>
<Clock3 class="h-3 w-3 mr-1" />
{{ t("driverStore.queued") }}
</Button>
<DriverInstallProgressCircle v-else-if="driver.installed && driver.update_available && isDriverProgressActive(driver.db_type)" :percent="getAgentProgressPercent(driver.db_type)" :title="getAgentProgressTitle(driver.db_type, t('driverStore.updating'))" />
<Button v-else-if="driver.installed && driver.update_available" size="sm" variant="outline" class="h-7 rounded-md text-xs" :disabled="upgradingAll || importingZip" @click="installDriver(driver.db_type)">
{{ t("driverStore.update") }}
</Button>
<Button v-if="driver.installed" variant="ghost" size="sm" class="h-7 rounded-md text-xs text-muted-foreground hover:text-destructive" :disabled="installing !== null || upgradingAll || importingZip || isDriverQueued(driver.db_type)" @click="uninstallDriver(driver.db_type)">
{{ t("driverStore.uninstall") }}
</Button>
</div>
</div>
</div>
</div>
</div>
<!-- Normal view: category-filtered stable drivers (updatable drivers are in the global banner above) -->
<div v-else class="driver-store-agent-list rounded-lg border divide-y">
<!-- Stable driver rows -->
<div
v-for="driver in categoryStableDrivers"
:key="driver.db_type"
:data-driver-store-focus="`driver:${driver.db_type}`"
class="driver-store-agent-row flex items-center gap-3 px-4 py-2 transition hover:bg-muted/30"
:class="{ 'driver-store-focus-highlight': highlightedFocusKey === `driver:${driver.db_type}` }"
>
<Clock3 class="h-3 w-3 mr-1" />
{{ t("driverStore.queued") }}
</Button>
<DriverInstallProgressCircle v-else-if="!driver.installed && isDriverProgressActive(driver.db_type)" :percent="getAgentProgressPercent(driver.db_type)" :title="getAgentProgressTitle(driver.db_type, t('driverStore.installing'))" />
<Button v-else-if="!driver.installed" size="sm" class="h-7 rounded-md text-xs" :disabled="upgradingAll || importingZip" @click="installDriver(driver.db_type)">
<Download class="h-3 w-3 mr-1" />
{{ t("driverStore.install") }}
</Button>
<Button
v-if="!driver.installed && !isPrestoSqlBuiltinDriver(driver.db_type) && !isDriverProgressActive(driver.db_type) && !isDriverQueued(driver.db_type)"
size="sm"
variant="ghost"
class="h-7 w-7 rounded-md text-xs text-muted-foreground"
:title="t('driverStore.importLocalJar')"
:disabled="upgradingAll || installing !== null || importingZip"
@click="importDriverFile(driver)"
>
<FileUp class="h-3.5 w-3.5" />
</Button>
<Check v-if="driver.installed && !(driver.update_available && isDriverProgressActive(driver.db_type))" class="h-4 w-4 text-green-600" />
<Button
v-if="driver.installed && driver.update_available && isDriverQueued(driver.db_type)"
size="sm"
variant="outline"
class="h-7 rounded-md border-green-500/30 bg-green-500/10 text-xs text-green-700 hover:bg-green-500/15"
:disabled="upgradingAll || importingZip"
@click="removeQueuedDriverInstall(driver.db_type)"
>
<Clock3 class="h-3 w-3 mr-1" />
{{ t("driverStore.queued") }}
</Button>
<DriverInstallProgressCircle v-else-if="driver.installed && driver.update_available && isDriverProgressActive(driver.db_type)" :percent="getAgentProgressPercent(driver.db_type)" :title="getAgentProgressTitle(driver.db_type, t('driverStore.updating'))" />
<Button v-else-if="driver.installed && driver.update_available" size="sm" variant="outline" class="h-7 rounded-md text-xs" :disabled="upgradingAll || importingZip" @click="installDriver(driver.db_type)">
{{ t("driverStore.update") }}
</Button>
<Button v-if="driver.installed" variant="ghost" size="sm" class="h-7 rounded-md text-xs text-muted-foreground hover:text-destructive" :disabled="installing !== null || upgradingAll || importingZip || isDriverQueued(driver.db_type)" @click="uninstallDriver(driver.db_type)">
{{ t("driverStore.uninstall") }}
</Button>
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-muted/60 shrink-0">
<DatabaseIcon :db-type="driver.db_type" class="h-4 w-4" />
</span>
<div class="driver-store-agent-name min-w-0 flex-1">
<div class="text-sm font-medium">{{ driver.label }}</div>
</div>
<div class="driver-store-agent-meta flex shrink-0 items-center gap-1.5">
<span v-if="driverRequiresJavaRuntime(driver) && driver.jre" class="rounded-full px-2 py-0.5 text-[11px]" :class="driver.jre !== '21' ? 'bg-blue-500/10 text-blue-600' : 'bg-muted text-muted-foreground'">JRE {{ driver.jre }}</span>
<span v-if="driver.installed" class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">v{{ driver.installed_version }}</span>
<span v-if="driver.installed && driver.update_available" class="rounded-full bg-amber-500/15 px-2 py-0.5 text-[11px] text-amber-600">→ v{{ driver.version }}</span>
<span v-if="!driver.installed && driver.version" class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">v{{ driver.version }}</span>
<span v-if="formatSize(driver.size)" class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">{{ formatSize(driver.size) }}</span>
</div>
<div class="driver-store-agent-actions flex shrink-0 items-center gap-2">
<Button
v-if="!driver.installed && isDriverQueued(driver.db_type)"
size="sm"
variant="outline"
class="h-7 rounded-md border-green-500/30 bg-green-500/10 text-xs text-green-700 hover:bg-green-500/15"
:disabled="upgradingAll || importingZip"
@click="removeQueuedDriverInstall(driver.db_type)"
>
<Clock3 class="h-3 w-3 mr-1" />
{{ t("driverStore.queued") }}
</Button>
<DriverInstallProgressCircle v-else-if="!driver.installed && isDriverProgressActive(driver.db_type)" :percent="getAgentProgressPercent(driver.db_type)" :title="getAgentProgressTitle(driver.db_type, t('driverStore.installing'))" />
<Button v-else-if="!driver.installed" size="sm" class="h-7 rounded-md text-xs" :disabled="upgradingAll || importingZip" @click="installDriver(driver.db_type)">
<Download class="h-3 w-3 mr-1" />
{{ t("driverStore.install") }}
</Button>
<Button
v-if="!driver.installed && !isPrestoSqlBuiltinDriver(driver.db_type) && !isDriverProgressActive(driver.db_type) && !isDriverQueued(driver.db_type)"
size="sm"
variant="ghost"
class="h-7 w-7 rounded-md text-xs text-muted-foreground"
:title="t('driverStore.importLocalJar')"
:disabled="upgradingAll || installing !== null || importingZip"
@click="importDriverFile(driver)"
>
<FileUp class="h-3.5 w-3.5" />
</Button>
<Check v-if="driver.installed && !(driver.update_available && isDriverProgressActive(driver.db_type))" class="h-4 w-4 text-green-600" />
<Button
v-if="driver.installed && driver.update_available && isDriverQueued(driver.db_type)"
size="sm"
variant="outline"
class="h-7 rounded-md border-green-500/30 bg-green-500/10 text-xs text-green-700 hover:bg-green-500/15"
:disabled="upgradingAll || importingZip"
@click="removeQueuedDriverInstall(driver.db_type)"
>
<Clock3 class="h-3 w-3 mr-1" />
{{ t("driverStore.queued") }}
</Button>
<DriverInstallProgressCircle v-else-if="driver.installed && driver.update_available && isDriverProgressActive(driver.db_type)" :percent="getAgentProgressPercent(driver.db_type)" :title="getAgentProgressTitle(driver.db_type, t('driverStore.updating'))" />
<Button v-else-if="driver.installed && driver.update_available" size="sm" variant="outline" class="h-7 rounded-md text-xs" :disabled="upgradingAll || importingZip" @click="installDriver(driver.db_type)">
{{ t("driverStore.update") }}
</Button>
<Button v-if="driver.installed" variant="ghost" size="sm" class="h-7 rounded-md text-xs text-muted-foreground hover:text-destructive" :disabled="installing !== null || upgradingAll || importingZip || isDriverQueued(driver.db_type)" @click="uninstallDriver(driver.db_type)">
{{ t("driverStore.uninstall") }}
</Button>
</div>
</div>
</div>
</div>
</div>
+2
View File
@@ -4593,6 +4593,8 @@ export default {
clearDownloadCache: "Clear cache",
downloadCacheClearSuccess: "Download cache cleared",
downloadCacheClearFailed: "Failed to clear download cache: {error}",
driverCategoryAll: "All Drivers",
driverCategoryLabel: "Category",
offlineDownloadHint: "For air-gapped environments, download offline driver packages on an internet-connected machine, then import them here.",
offlineDownloadLink: "Offline driver downloads",
searchDrivers: "Search driver name, type, version...",
+2
View File
@@ -4360,6 +4360,8 @@ export default withEnglishFallback({
clearDownloadCache: "Limpiar caché",
downloadCacheClearSuccess: "Caché de descargas limpiada",
downloadCacheClearFailed: "Error al limpiar la caché de descargas: {error}",
driverCategoryAll: "All Drivers",
driverCategoryLabel: "Category",
offlineDownloadHint: "Para entornos sin conexión a internet, descarga los paquetes de drivers offline en una máquina con conexión, luego impórtalos aquí.",
offlineDownloadLink: "Descargas de drivers offline",
searchDrivers: "Buscar nombre de driver, tipo, versión...",
+2
View File
@@ -4358,6 +4358,8 @@ export default withEnglishFallback({
clearDownloadCache: "Pulisci cache",
downloadCacheClearSuccess: "Cache download pulita",
downloadCacheClearFailed: "Pulizia cache download non riuscita: {error}",
driverCategoryAll: "All Drivers",
driverCategoryLabel: "Category",
offlineDownloadHint: "Per ambienti air-gapped, scarica i pacchetti driver offline su una macchina connessa a Internet, quindi importali qui.",
offlineDownloadLink: "Download driver offline",
searchDrivers: "Cerca nome driver, tipo, versione...",
+2
View File
@@ -4358,6 +4358,8 @@ export default withEnglishFallback({
clearDownloadCache: "キャッシュを削除",
downloadCacheClearSuccess: "ダウンロードキャッシュを削除しました",
downloadCacheClearFailed: "ダウンロードキャッシュの削除に失敗しました: {error}",
driverCategoryAll: "All Drivers",
driverCategoryLabel: "Category",
offlineDownloadHint: "エアギャップ環境の場合は、インターネット接続されたマシンでオフラインドライバーパッケージをダウンロードし、ここでインポートしてください。",
offlineDownloadLink: "オフラインドライバーダウンロード",
searchDrivers: "ドライバー名、タイプ、バージョンを検索...",
+2
View File
@@ -4360,6 +4360,8 @@ export default withEnglishFallback({
clearDownloadCache: "Limpar cache",
downloadCacheClearSuccess: "Cache de downloads limpo",
downloadCacheClearFailed: "Falha ao limpar cache de downloads: {error}",
driverCategoryAll: "All Drivers",
driverCategoryLabel: "Category",
offlineDownloadHint: "Para ambientes sem internet, baixe pacotes de driver offline em uma máquina com acesso à internet e importe-os aqui.",
offlineDownloadLink: "Downloads de driver offline",
searchDrivers: "Pesquisar nome, tipo, versão do driver...",
+2
View File
@@ -4592,6 +4592,8 @@ export default withEnglishFallback({
clearDownloadCache: "清理缓存",
downloadCacheClearSuccess: "下载缓存已清理",
downloadCacheClearFailed: "下载缓存清理失败: {error}",
driverCategoryAll: "全部驱动",
driverCategoryLabel: "分类",
offlineDownloadHint: "内网环境可先在有网机器下载离线驱动包,再回到这里导入。",
offlineDownloadLink: "离线驱动下载",
searchDrivers: "搜索驱动名称、类型、版本...",
+2
View File
@@ -4027,6 +4027,8 @@ export default withEnglishFallback({
clearDownloadCache: "清理快取",
downloadCacheClearSuccess: "下載快取已清理",
downloadCacheClearFailed: "下載快取清理失敗: {error}",
driverCategoryAll: "全部驅動",
driverCategoryLabel: "分類",
javaRuntimeSaved: "Java 執行環境設定已儲存",
javaRuntimeSaveFailed: "Java 執行環境設定失敗: {error}",
chooseJavaExecutable: "選擇 Java 可執行檔",
@@ -0,0 +1,151 @@
import { describe, expect, it } from "vitest";
import { AGENT_DRIVER_CATEGORY_MAP, DRIVER_CATEGORIES, assertAgentDriverCategoriesComplete, getCategoryForAgentDriver } from "@/lib/connection/driver-category-definitions";
describe("getCategoryForAgentDriver", () => {
it("returns correct category for one key from each of the 9 categories", () => {
// sql
expect(getCategoryForAgentDriver("oracle")).toBe("sql");
// analytics
expect(getCategoryForAgentDriver("snowflake")).toBe("analytics");
// domestic
expect(getCategoryForAgentDriver("dameng")).toBe("domestic");
// lightweight
expect(getCategoryForAgentDriver("access")).toBe("lightweight");
// document
expect(getCategoryForAgentDriver("mongodb")).toBe("document");
// graph_ai
expect(getCategoryForAgentDriver("neo4j")).toBe("graph_ai");
// timeseries
expect(getCategoryForAgentDriver("tdengine")).toBe("timeseries");
// mq
expect(getCategoryForAgentDriver("kafka")).toBe("mq");
// registry_config
expect(getCategoryForAgentDriver("etcd")).toBe("registry_config");
});
it('returns "all" for unknown keys', () => {
expect(getCategoryForAgentDriver("")).toBe("all");
expect(getCategoryForAgentDriver("unknown_driver_xyz")).toBe("all");
expect(getCategoryForAgentDriver("nosuchdriver")).toBe("all");
expect(getCategoryForAgentDriver("random-string-123")).toBe("all");
});
});
describe("assertAgentDriverCategoriesComplete", () => {
it("does not throw when all keys mapped", () => {
const mappedKeys = Object.keys(AGENT_DRIVER_CATEGORY_MAP);
expect(() => assertAgentDriverCategoriesComplete(mappedKeys)).not.toThrow();
});
it("throws when a key is missing", () => {
const mappedKeys = Object.keys(AGENT_DRIVER_CATEGORY_MAP);
expect(() => assertAgentDriverCategoriesComplete([...mappedKeys, "no_such_driver"])).toThrow("unmapped=no_such_driver");
});
});
describe("AGENT_DRIVER_CATEGORY_MAP integrity", () => {
it("has no agent driver key mapped to more than one category (i.e. no duplicate keys)", () => {
const entries = Object.entries(AGENT_DRIVER_CATEGORY_MAP);
const keys = entries.map(([key]) => key);
// Each key in a Record is already unique by definition, but verify
// there are no unexpected dupes in the data.
const seen = new Set<string>();
for (const k of keys) {
expect(seen.has(k)).toBe(false);
seen.add(k);
}
});
it("has all category values listed in DRIVER_CATEGORIES", () => {
const validCategoryKeys = new Set(DRIVER_CATEGORIES.map((c) => c.key));
for (const [driverKey, category] of Object.entries(AGENT_DRIVER_CATEGORY_MAP)) {
expect(validCategoryKeys.has(category), `Driver "${driverKey}" maps to unknown category "${category}"`).toBe(true);
}
});
it("covers all known agent driver keys with no stale entries", () => {
// Golden set — mirrors the store-visible entries in agent_catalog.rs
// plus EXTRA_DRIVER_STORE_ENTRIES (kafka, rocketmq, rabbitmq,
// sqlserver-legacy) and the PrestoSQL built-in driver.
// When an Agent driver is added to the catalog its key MUST appear here;
// removing a key from the map without removing it from the list below
// will fail this test.
const expectedKeys = new Set([
// sql
"db2",
"firebird",
"informix",
"iris",
"oracle",
"sqlserver-legacy",
// analytics
"bigquery",
"databend",
"databricks",
"exasol",
"hive",
"kylin",
"prestosql",
"saphana",
"snowflake",
"spark",
"teradata",
"trino",
"vertica",
// domestic
"dameng",
"gbase8a",
"gbase8s",
"goldendb",
"highgo",
"kingbase",
"oceanbase-oracle",
"oscar",
"sundb",
"uxdb",
"vastbase",
"xugu",
"yashandb",
// lightweight
"access",
"h2",
"h2-legacy",
// document
"cassandra",
"mongodb",
// graph_ai
"neo4j",
// timeseries
"influxdb",
"iotdb",
"tdengine",
// mq
"kafka",
"rabbitmq",
"rocketmq",
// registry_config
"etcd",
"zookeeper",
]);
const actualKeys = Object.keys(AGENT_DRIVER_CATEGORY_MAP);
expect(actualKeys).toHaveLength(expectedKeys.size);
const actualSet = new Set(actualKeys);
const missing = [...expectedKeys].filter((k) => !actualSet.has(k));
const extra = actualKeys.filter((k) => !expectedKeys.has(k));
expect(missing, `Missing from AGENT_DRIVER_CATEGORY_MAP: ${missing.join(", ")}`).toEqual([]);
expect(extra, `Extra keys in AGENT_DRIVER_CATEGORY_MAP not in expected set: ${extra.join(", ")}`).toEqual([]);
});
});
describe("prestosql matches ConnectionDialog category", () => {
it("is mapped to analytics (same as ConnectionDialog dbCategoryDefinitions)", () => {
expect(getCategoryForAgentDriver("prestosql")).toBe("analytics");
});
});
@@ -0,0 +1,193 @@
import { describe, expect, it } from "vitest";
import { selectStableDrivers, selectUpdatableDrivers, hasAnyUpdatableDriverMatching } from "@/lib/connection/driverListFilter";
import type { AgentDriverInfo } from "@/lib/backend/api";
function driver(overrides: Partial<AgentDriverInfo> = {}): AgentDriverInfo {
return {
db_type: "test",
label: "Test",
version: "1.0",
size: 0,
installed: true,
installed_version: "1.0",
update_available: false,
jre: "",
jre_installed: false,
...overrides,
};
}
describe("selectUpdatableDrivers", () => {
it("returns only drivers with update_available === true", () => {
const drivers: AgentDriverInfo[] = [driver({ db_type: "mysql", update_available: true }), driver({ db_type: "postgres", update_available: false }), driver({ db_type: "sqlite", update_available: true })];
const result = selectUpdatableDrivers(drivers);
expect(result).toHaveLength(2);
expect(result.map((d) => d.db_type)).toEqual(["mysql", "sqlite"]);
});
it("returns empty array when no drivers have updates available", () => {
const drivers: AgentDriverInfo[] = [driver({ db_type: "mysql", update_available: false }), driver({ db_type: "postgres", update_available: false })];
expect(selectUpdatableDrivers(drivers)).toEqual([]);
});
it("returns empty array for an empty input", () => {
expect(selectUpdatableDrivers([])).toEqual([]);
});
});
describe("selectStableDrivers", () => {
it("returns only drivers with update_available === false", () => {
const drivers: AgentDriverInfo[] = [driver({ db_type: "mysql", update_available: true }), driver({ db_type: "postgres", update_available: false }), driver({ db_type: "sqlite", update_available: false })];
const result = selectStableDrivers(drivers);
expect(result).toHaveLength(2);
expect(result.map((d) => d.db_type)).toEqual(["postgres", "sqlite"]);
});
it("returns all drivers when none have updates", () => {
const drivers: AgentDriverInfo[] = [driver({ db_type: "mysql", update_available: false }), driver({ db_type: "postgres", update_available: false })];
expect(selectStableDrivers(drivers)).toHaveLength(2);
});
it("returns empty array when all drivers have updates", () => {
const drivers: AgentDriverInfo[] = [driver({ db_type: "mysql", update_available: true }), driver({ db_type: "sqlite", update_available: true })];
expect(selectStableDrivers(drivers)).toEqual([]);
});
});
describe("partition invariant", () => {
it("updatable + stable drivers cover the full input with no overlap", () => {
const drivers: AgentDriverInfo[] = [driver({ db_type: "mysql", update_available: true }), driver({ db_type: "postgres", update_available: false }), driver({ db_type: "sqlite", update_available: true }), driver({ db_type: "oracle", update_available: false })];
const updatable = selectUpdatableDrivers(drivers);
const stable = selectStableDrivers(drivers);
// No duplicate db_types between the two sets.
const updatableKeys = new Set(updatable.map((d) => d.db_type));
for (const d of stable) {
expect(updatableKeys.has(d.db_type)).toBe(false);
}
// Union is the full set.
expect(updatable.length + stable.length).toBe(drivers.length);
});
});
describe("search deduplication", () => {
it("updatable drivers are excluded from stable so they only appear in the global update section", () => {
const drivers: AgentDriverInfo[] = [driver({ db_type: "neo4j", label: "Neo4j", update_available: true }), driver({ db_type: "oracle", label: "Oracle", update_available: true }), driver({ db_type: "postgres", label: "PostgreSQL", update_available: false })];
const stable = selectStableDrivers(drivers);
// Searching for "Neo4j" in stable should produce no results — the driver
// already appears in the global update section above and must not be
// duplicated in the category-filtered search results below.
const searchHit = stable.some((d) => d.db_type === "neo4j");
expect(searchHit).toBe(false);
});
});
// ---------------------------------------------------------------------------
// hasAnyUpdatableDriverMatching — regression tests for empty‑state suppression
// (DriverStoreDialog.vue)
// ---------------------------------------------------------------------------
/** Simple matchers mirroring the component's real predicates. */
function labelSearch(d: AgentDriverInfo, q: string) {
return d.label.toLowerCase().includes(q);
}
function categoryOf(d: AgentDriverInfo) {
// Map db_type prefixes to categories — the real mapping lives in
// getCategoryForAgentDriver; this simplified version is sufficient for tests.
if (d.db_type === "neo4j") return "graph";
return "relational";
}
describe("hasAnyUpdatableDriverMatching", () => {
const neo4j = driver({ db_type: "neo4j", label: "Neo4j", update_available: true });
const oracle = driver({ db_type: "oracle", label: "Oracle", update_available: true });
it("returns false when there are no updatable drivers", () => {
expect(
hasAnyUpdatableDriverMatching([], {
searchQuery: "",
selectedCategory: "all",
driverMatchesSearch: labelSearch,
driverCategory: categoryOf,
}),
).toBe(false);
});
it("returns true for 'all' category with no search when updatable drivers exist", () => {
expect(
hasAnyUpdatableDriverMatching([neo4j], {
searchQuery: "",
selectedCategory: "all",
driverMatchesSearch: labelSearch,
driverCategory: categoryOf,
}),
).toBe(true);
});
it("returns true when an updatable driver matches the search", () => {
expect(
hasAnyUpdatableDriverMatching([neo4j, oracle], {
searchQuery: "neo4j",
selectedCategory: "all",
driverMatchesSearch: labelSearch,
driverCategory: categoryOf,
}),
).toBe(true);
});
it("returns false when no updatable driver matches the search (regression: search 'zzzz' with Neo4j update)", () => {
expect(
hasAnyUpdatableDriverMatching([neo4j], {
searchQuery: "zzzz",
selectedCategory: "all",
driverMatchesSearch: labelSearch,
driverCategory: categoryOf,
}),
).toBe(false);
});
it("returns true when an updatable driver belongs to the selected category", () => {
expect(
hasAnyUpdatableDriverMatching([neo4j, oracle], {
searchQuery: "",
selectedCategory: "graph",
driverMatchesSearch: labelSearch,
driverCategory: categoryOf,
}),
).toBe(true);
});
it("returns false when no updatable driver belongs to the selected category (regression: Neo4j update, 'relational' category)", () => {
expect(
hasAnyUpdatableDriverMatching([neo4j], {
searchQuery: "",
selectedCategory: "relational",
driverMatchesSearch: labelSearch,
driverCategory: categoryOf,
}),
).toBe(false);
});
it("search takes precedence over category when both are active", () => {
// Neo4j is in "graph" category; user selected "graph" but searched "oracle".
expect(
hasAnyUpdatableDriverMatching([neo4j, oracle], {
searchQuery: "oracle",
selectedCategory: "graph",
driverMatchesSearch: labelSearch,
driverCategory: categoryOf,
}),
).toBe(true);
});
});
@@ -0,0 +1,116 @@
export const DRIVER_CATEGORIES = [
{ key: "sql", order: 1, titleKey: "connection.databaseCategorySql" },
{ key: "analytics", order: 2, titleKey: "connection.databaseCategoryAnalytics" },
{ key: "domestic", order: 3, titleKey: "connection.databaseCategoryDomestic" },
{ key: "lightweight", order: 4, titleKey: "connection.databaseCategoryLightweight" },
{ key: "document", order: 5, titleKey: "connection.databaseCategoryDocument" },
{ key: "graph_ai", order: 6, titleKey: "connection.databaseCategoryGraphAi" },
{ key: "timeseries", order: 7, titleKey: "connection.databaseCategoryTimeseries" },
{ key: "mq", order: 8, titleKey: "connection.databaseCategoryMq" },
{ key: "registry_config", order: 9, titleKey: "connection.databaseCategoryRegistryConfig" },
] as const;
export type DriverCategoryKey = (typeof DRIVER_CATEGORIES)[number]["key"];
const VALID_CATEGORY_KEYS: ReadonlySet<string> = new Set(DRIVER_CATEGORIES.map((cat) => cat.key));
const EMPTY = 0;
export const AGENT_DRIVER_CATEGORY_MAP: Readonly<Record<string, DriverCategoryKey>> = {
access: "lightweight",
bigquery: "analytics",
cassandra: "document",
dameng: "domestic",
databend: "analytics",
databricks: "analytics",
db2: "sql",
etcd: "registry_config",
exasol: "analytics",
firebird: "sql",
gbase8a: "domestic",
gbase8s: "domestic",
goldendb: "domestic",
h2: "lightweight",
"h2-legacy": "lightweight",
highgo: "domestic",
hive: "analytics",
influxdb: "timeseries",
informix: "sql",
iotdb: "timeseries",
iris: "sql",
kafka: "mq",
kingbase: "domestic",
kylin: "analytics",
mongodb: "document",
neo4j: "graph_ai",
"oceanbase-oracle": "domestic",
oracle: "sql",
oscar: "domestic",
prestosql: "analytics",
rabbitmq: "mq",
rocketmq: "mq",
saphana: "analytics",
snowflake: "analytics",
spark: "analytics",
"sqlserver-legacy": "sql",
sundb: "domestic",
tdengine: "timeseries",
teradata: "analytics",
trino: "analytics",
uxdb: "domestic",
vastbase: "domestic",
vertica: "analytics",
xugu: "domestic",
yashandb: "domestic",
zookeeper: "registry_config",
};
export const getCategoryForAgentDriver = (dbType: string): DriverCategoryKey | "all" => AGENT_DRIVER_CATEGORY_MAP[dbType] ?? "all";
const collectUnmapped = (driverKeys: string[]): string[] => driverKeys.filter((key) => !(key in AGENT_DRIVER_CATEGORY_MAP));
const collectUnknownCategories = (): string[] =>
Object.entries(AGENT_DRIVER_CATEGORY_MAP)
.filter(([, category]) => !VALID_CATEGORY_KEYS.has(category))
.map(([key, category]) => `${key}->${category}`);
const collectDuplicateKeys = (driverKeys: string[]): string[] => driverKeys.filter((key, index) => driverKeys.indexOf(key) !== index).filter((key, index, arr) => arr.indexOf(key) === index);
const formatErrorMessage = (unmapped: string[], unknownCategories: string[], duplicateKeys: string[]): string => {
const parts: string[] = [];
if (unmapped.length > EMPTY) {
parts.push(`unmapped=${unmapped.join(",")}`);
}
if (unknownCategories.length > EMPTY) {
parts.push(`unknownCategories=${unknownCategories.join(",")}`);
}
if (duplicateKeys.length > EMPTY) {
parts.push(`duplicateKeys=${duplicateKeys.join(",")}`);
}
return parts.join("; ");
};
/**
* Validates that every driver key in {@link agentDriverDbTypes} has a category
* mapping and that all mapped categories are valid. Throws if any driver is
* unmapped, duplicates appear, or an unknown category is referenced.
*/
export const assertAgentDriverCategoriesComplete = (agentDriverDbTypes: string[]): void => {
const unmapped = collectUnmapped(agentDriverDbTypes);
const unknownCategories = collectUnknownCategories();
const duplicateKeys = collectDuplicateKeys(agentDriverDbTypes);
if (unmapped.length > EMPTY || unknownCategories.length > EMPTY || duplicateKeys.length > EMPTY) {
throw new Error(formatErrorMessage(unmapped, unknownCategories, duplicateKeys));
}
// Warn in dev if the map has stale entries for drivers not in the catalog.
if (import.meta.env.DEV) {
const driverSet = new Set(agentDriverDbTypes);
const mappedButNotListed = Object.keys(AGENT_DRIVER_CATEGORY_MAP).filter((key) => !driverSet.has(key));
if (mappedButNotListed.length > EMPTY) {
// eslint-disable-next-line no-console
console.warn("[driver-category-definitions] agent driver category map has entries for drivers not in the supplied list:", mappedButNotListed.join(", "));
}
}
};
@@ -0,0 +1,48 @@
import type { AgentDriverInfo } from "@/lib/backend/api";
/**
* Drivers that have an update available — always rendered above category
* navigation so the user can see and act on them regardless of the active
* category filter.
*/
export function selectUpdatableDrivers(drivers: readonly AgentDriverInfo[]): AgentDriverInfo[] {
return drivers.filter((d) => d.update_available);
}
/**
* Category-filtered drivers *excluding* updatable ones — the per‑item
* "Update" action lives in the global update section, so the category
* list only renders stable (non‑updatable) rows.
*/
export function selectStableDrivers(drivers: readonly AgentDriverInfo[]): AgentDriverInfo[] {
return drivers.filter((d) => !d.update_available);
}
export interface UpdatableDriverMatchOptions {
/** Lowercased search query (empty string when search is inactive). */
searchQuery: string;
/** Currently selected category key ("all" when no specific category). */
selectedCategory: string;
/** Predicate: does `driver` match `query` (already lowercased)? */
driverMatchesSearch: (driver: AgentDriverInfo, query: string) => boolean;
/** Returns the category key for `driver`. */
driverCategory: (driver: AgentDriverInfo) => string;
}
/**
* Returns `true` when at least one driver in `updatableDrivers` is relevant to
* the current view (search query or selected category). Used to decide whether
* an empty‑state message should be suppressed: the global update section always
* renders *all* updatable drivers, so the empty‑state must only be hidden when
* at least one of those drivers is actually relevant.
*/
export function hasAnyUpdatableDriverMatching(updatableDrivers: readonly AgentDriverInfo[], opts: UpdatableDriverMatchOptions): boolean {
if (updatableDrivers.length === 0) return false;
if (opts.searchQuery) {
return updatableDrivers.some((d) => opts.driverMatchesSearch(d, opts.searchQuery));
}
if (opts.selectedCategory !== "all") {
return updatableDrivers.some((d) => opts.driverCategory(d) === opts.selectedCategory);
}
return true;
}