diff --git a/apps/desktop/public/icons/database/hbase.svg b/apps/desktop/public/icons/database/hbase.svg
new file mode 100644
index 0000000000..1e0df2e379
--- /dev/null
+++ b/apps/desktop/public/icons/database/hbase.svg
@@ -0,0 +1,3 @@
+
diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue
index f212f52c40..7897735d53 100644
--- a/apps/desktop/src/components/grid/DataGrid.vue
+++ b/apps/desktop/src/components/grid/DataGrid.vue
@@ -299,6 +299,7 @@ interface DataGridProps {
totalRowCount?: number;
totalRowCountIsExact?: boolean;
paginationTotalRowCount?: number;
+ paginationEnabled?: boolean;
totalRowCountLoading?: boolean;
loading?: boolean;
cacheKey?: string;
@@ -319,6 +320,7 @@ const props = withDefaults(defineProps(), {
// Vue casts absent Boolean props to false unless a default is explicit.
// Regular grids have exact totals; document stores opt into lower-bound totals.
totalRowCountIsExact: true,
+ paginationEnabled: true,
// Omitted row-action limits must keep normal table-data editing.
allowInsertRows: undefined,
allowDeleteRows: undefined,
@@ -461,7 +463,7 @@ const compactColumnHeaderActions = computed(() => settingsStore.editorSettings.c
const dataGridRenderMode = computed(() => settingsStore.editorSettings.dataGridRenderMode);
const dataGridSearchMode = computed(() => settingsStore.editorSettings.dataGridSearchMode);
const compactDataGridToolbar = computed(() => dataGridTopbarWidth.value > 0 && dataGridTopbarWidth.value < DATA_GRID_COMPACT_TOPBAR_WIDTH);
-const infiniteScrollEnabled = computed(() => settingsStore.editorSettings.infiniteScroll);
+const infiniteScrollEnabled = computed(() => props.paginationEnabled && settingsStore.editorSettings.infiniteScroll);
const infiniteScrollMaxRows = computed(() => settingsStore.editorSettings.infiniteScrollMaxRows);
const expandedCellEditor = ref<{ rowId: number; col: number } | null>(null);
@@ -9178,6 +9180,7 @@ const gridContextMenuItems = computed(() => {
();
+withDefaults(
+ defineProps<{
+ paginationEnabled?: boolean;
+ selectionSummary: { cellCount: number; rowCount: number } | null;
+ selectionSummarySumText: string;
+ loading: boolean;
+ infiniteScrollEnabled: boolean;
+ infiniteScrollAllLoaded: boolean;
+ pageSize: number;
+ pageSizeMenuItems: LightDropdownItem[];
+ exportMenuItems: LightDropdownItem[];
+ currentPage: number;
+ canGoNextPage: boolean;
+ canJumpLastPage: boolean;
+ }>(),
+ { paginationEnabled: true },
+);
const customPageSizeInput = defineModel("customPageSizeInput", { default: "" });
@@ -47,10 +51,10 @@ const emit = defineEmits<{
-
+
{{ t("grid.allLoaded") }}
-
+
{
expect(nextPage).toHaveBeenCalledOnce();
expect(lastPage).toHaveBeenCalledOnce();
});
+
+ it("hides pagination controls when the data source does not support paging", () => {
+ const mounted = mountComponent(DataGridPagination, {
+ paginationEnabled: false,
+ selectionSummary: null,
+ selectionSummarySumText: "",
+ loading: false,
+ infiniteScrollEnabled: false,
+ infiniteScrollAllLoaded: false,
+ pageSize: 100,
+ customPageSizeInput: "",
+ pageSizeMenuItems: [],
+ exportMenuItems: [],
+ currentPage: 1,
+ canGoNextPage: false,
+ canJumpLastPage: false,
+ });
+
+ expect(findAll(mounted.root, (node) => node.props["data-stub"] === "Button" && node.props.class === "h-5 w-5 shrink-0")).toHaveLength(0);
+ });
});
describe("DataGridColumnHeader", () => {
diff --git a/apps/desktop/src/components/hbase/HBaseBrowser.vue b/apps/desktop/src/components/hbase/HBaseBrowser.vue
index 915a2c895d..99a42e8201 100644
--- a/apps/desktop/src/components/hbase/HBaseBrowser.vue
+++ b/apps/desktop/src/components/hbase/HBaseBrowser.vue
@@ -15,6 +15,7 @@ import { useToast } from "@/composables/useToast";
import * as api from "@/lib/backend/api";
import type { CellValue } from "@/lib/dataGrid/cellValue";
import { encodeHBaseTextInput, hbaseCellInput } from "@/lib/hbase/hbaseValues";
+import { loadHBaseRowLimit, saveHBaseRowLimit } from "@/lib/hbase/hbaseBrowserPreferences";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import type { QueryResult } from "@/types/database";
@@ -40,7 +41,7 @@ const loading = ref(false);
const error = ref("");
const lookupMode = ref("prefix");
const rowKeyInput = ref("");
-const rowLimit = ref("100");
+const rowLimit = ref(loadHBaseRowLimit());
const truncated = ref(false);
const elapsedMs = ref(0);
const schema = ref();
@@ -123,6 +124,10 @@ watch(
{ immediate: true },
);
+watch(rowLimit, (value) => {
+ saveHBaseRowLimit(value);
+});
+
async function refreshRows() {
if (loading.value || !hasTable.value) return;
loading.value = true;
@@ -229,7 +234,7 @@ async function createTable() {
await api.hbaseCreateTable(props.connectionId, props.namespace, createdTable, families);
createTableDialogOpen.value = false;
toast(t("hbase.tableCreated", { table: createdTable }));
- await connectionStore.loadTables(props.connectionId, props.namespace);
+ await connectionStore.refreshObjectListTreeNode(props.connectionId, props.namespace);
if (!hasTable.value) {
const tab = queryStore.tabs.find((candidate) => candidate.id === props.tabId);
if (tab) tab.title = createdTable;
@@ -249,7 +254,7 @@ async function deleteTable() {
try {
await api.hbaseDeleteTable(props.connectionId, props.namespace, props.table);
deleteTableDialogOpen.value = false;
- await connectionStore.loadTables(props.connectionId, props.namespace);
+ await connectionStore.refreshObjectListTreeNode(props.connectionId, props.namespace);
queryStore.closeTab(props.tabId);
toast(t("hbase.tableDeleted", { table: qualifiedTableLabel.value }));
} catch (caught) {
@@ -418,6 +423,7 @@ function errorMessage(value: unknown): string {
:allow-delete-rows="!readOnly"
:loading="loading"
:page-limit="Number(rowLimit)"
+ :pagination-enabled="false"
:total-row-count="rows.length"
:total-row-count-is-exact="!truncated"
@reload="refreshRows"
diff --git a/apps/desktop/src/components/icons/DatabaseIcon.vue b/apps/desktop/src/components/icons/DatabaseIcon.vue
index 192d0037c5..f5aa8751da 100644
--- a/apps/desktop/src/components/icons/DatabaseIcon.vue
+++ b/apps/desktop/src/components/icons/DatabaseIcon.vue
@@ -52,6 +52,7 @@ const assetIcons: Record = {
presto: "presto",
prestosql: "presto",
hive: "hive",
+ hbase: "hbase",
spark: "spark-logo.png",
apache_kylin: "apache_kylin",
sundb: "sundb",
diff --git a/apps/desktop/src/components/layout/AppTabBar.vue b/apps/desktop/src/components/layout/AppTabBar.vue
index d370defb8a..2e7e2f0ef0 100644
--- a/apps/desktop/src/components/layout/AppTabBar.vue
+++ b/apps/desktop/src/components/layout/AppTabBar.vue
@@ -445,7 +445,7 @@ function tabColorStyle(tab: QueryTab) {
function tabIconClass(tab: QueryTab) {
if (tab.mode === "mq") return "";
- if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "vector" || tab.mode === "redis" || tab.mode === "objects" || tab.mode === "structure") return "text-emerald-600 dark:text-emerald-400";
+ if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "vector" || tab.mode === "redis" || tab.mode === "hbase" || tab.mode === "objects" || tab.mode === "structure") return "text-emerald-600 dark:text-emerald-400";
return "text-blue-600 dark:text-blue-400";
}
@@ -472,7 +472,7 @@ const tabBarClass = computed(() => [isClassicLayout.value ? "bg-muted" : "border
const regularTabRowClass = computed(() => [isClassicLayout.value ? "h-9 items-stretch" : "h-10 items-center px-2", isClassicLayout.value && !hasFixedTabs.value ? "border-b" : ""]);
function tabMenuIcon(tab: QueryTab) {
- if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "redis") return Table2;
+ if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "redis" || tab.mode === "hbase") return Table2;
if (tab.mode === "vector") return TableProperties;
if (tab.mode === "etcd" || tab.mode === "zookeeper") return KeyRound;
if (tab.mode === "etcd-dashboard") return Gauge;
@@ -615,7 +615,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
@mouseleave="tabDrag.clearTarget(tab.id)"
>
-
+
@@ -809,7 +809,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
@mouseleave="tabDrag.clearTarget(tab.id)"
>
-
+
diff --git a/apps/desktop/src/components/layout/__tests__/AppTabBar.spec.ts b/apps/desktop/src/components/layout/__tests__/AppTabBar.spec.ts
index 2d30ba14de..4ce55a363a 100644
--- a/apps/desktop/src/components/layout/__tests__/AppTabBar.spec.ts
+++ b/apps/desktop/src/components/layout/__tests__/AppTabBar.spec.ts
@@ -18,3 +18,11 @@ describe("AppTabBar close confirmation layout", () => {
expect(tabBarSource).toContain('@click="handleCancelClose"');
});
});
+
+describe("AppTabBar HBase presentation", () => {
+ it("uses the table icon in regular, pinned, and overflow tab surfaces", () => {
+ expect(tabBarSource).toContain('if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "redis" || tab.mode === "hbase") return Table2;');
+ expect(tabBarSource.match(/tab\.mode === 'hbase'/g)).toHaveLength(2);
+ expect(tabBarSource).toContain('tab.mode === "hbase" || tab.mode === "objects"');
+ });
+});
diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue
index 9a549d0fc6..6ad13eec86 100644
--- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue
+++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue
@@ -90,6 +90,7 @@ import {
import { copyNameForTreeNode, isDocumentBrowserTreeNode, objectSourceKindForTreeNode, shouldRunTreeNodeRowAction, treeNodeRowAction, treeNodeRowDoubleClickAction } from "@/lib/sidebar/treeNodeClick";
import { dataTabOpenModeFromTreeClick, type DataTabOpenMode } from "@/lib/sidebar/dataTabOpenPolicy";
import { isCopySidebarSelectionShortcut, isEditSidebarConnectionShortcut, isPasteSidebarSelectionShortcut } from "@/lib/editor/keyboardShortcuts";
+import { handleSidebarTreeDeleteShortcut } from "@/lib/sidebar/sidebarTreeDeleteShortcut";
import { dataTableDoubleClickAction } from "@/lib/tabs/dataTabActivation";
import { attachedDatabaseNameFromPath, buildCreateDatabaseSql, buildDuckDbAttachDatabaseSql, buildSqliteAttachDatabaseSql, supportsCreateDatabaseCharset, uniqueAttachedDatabaseName } from "@/lib/database/createDatabaseSql";
import { appendCreateDatabaseErrorHint } from "@/lib/database/createDatabaseErrorHints";
@@ -779,10 +780,20 @@ function onKeydown(event: KeyboardEvent) {
event.stopPropagation();
return;
}
- if (!event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey && isDeleteTreeNodeShortcut(event)) {
- if (!requestDeleteSelectedNode()) return;
- event.preventDefault();
- event.stopPropagation();
+ if (
+ handleSidebarTreeDeleteShortcut(event, {
+ activeNode: activeNode.value,
+ selectedNodes: selectedTreeNodesInVisibleOrder(),
+ databaseTypeForNode,
+ requestHBaseTableDelete: () => {
+ ensureDangerDialogRouting();
+ routeTreeItemDialogController();
+ requestDeleteHBaseTable();
+ return true;
+ },
+ requestDefaultDelete: requestDeleteSelectedNode,
+ })
+ ) {
return;
}
if (!isCopyTreeSelectionShortcut(event)) return;
@@ -791,10 +802,6 @@ function onKeydown(event: KeyboardEvent) {
copySelectedNames();
}
-function isDeleteTreeNodeShortcut(event: KeyboardEvent): boolean {
- return event.key === "Delete" || event.key === "Backspace";
-}
-
function isPasteTreeClipboardShortcut(event: KeyboardEvent): boolean {
return isPasteSidebarSelectionShortcut(event, settingsStore.editorSettings.shortcuts);
}
@@ -2936,6 +2943,26 @@ function createView() {
});
}
+function requestDeleteHBaseTable() {
+ showHBaseDeleteTableConfirm.value = true;
+}
+
+async function confirmDeleteHBaseTable() {
+ const node = sidebarDangerTarget.value ?? activeNode.value;
+ if (!node.connectionId || !node.database || connectionStore.getConfig(node.connectionId)?.db_type !== "hbase") return;
+ try {
+ await connectionStore.ensureConnected(node.connectionId);
+ await api.hbaseDeleteTable(node.connectionId, node.database, node.label);
+ closeDroppedTableObjectTabsForNode(node);
+ connectionStore.removePinnedTreeNodes([node]);
+ await connectionStore.refreshObjectListTreeNode(node.connectionId, node.database);
+ toast(t("hbase.tableDeleted", { table: node.database === "default" ? node.label : `${node.database}:${node.label}` }));
+ } catch (error: any) {
+ toast(t("contextMenu.tableOperationFailed", { message: error?.message || String(error) }), 5000);
+ throw error;
+ }
+}
+
function createMysqlObjectTemplate() {
const node = activeNode.value;
if (!node.connectionId || !node.database) return;
@@ -3002,6 +3029,7 @@ const isSelected = computed(() => connectionStore.selectedTreeNodeId === activeN
const isMultiSelected = computed(() => connectionStore.selectedTreeNodeIdsSet.has(activeNode.value.id));
const dangerDialogRoutes: Array<{ flag: { value: boolean }; createRequest: () => SidebarDangerDialogRequest }> = [];
+const showHBaseDeleteTableConfirm = shallowRef(false);
let stopDangerDialogRouting: (() => void) | null = null;
@@ -3068,6 +3096,17 @@ routeDangerDialog(showDropTableConfirm, () =>
}),
);
+routeDangerDialog(showHBaseDeleteTableConfirm, () => {
+ const table = activeNode.value.database && activeNode.value.database !== "default" ? `${activeNode.value.database}:${activeNode.value.label}` : activeNode.value.label;
+ return dangerRequest({
+ title: t("hbase.deleteTable"),
+ message: t("hbase.deleteTableConfirm", { table }),
+ details: table,
+ confirmLabel: t("common.delete"),
+ confirm: confirmDeleteHBaseTable,
+ });
+});
+
routeDangerDialog(showEmptyTableConfirm, () =>
dangerRequest({
title: t("contextMenu.confirmEmptyTableTitle"),
@@ -3848,6 +3887,16 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
icon: RefreshCw,
shortcut: shortcutRefresh,
});
+ if (!connectionStore.getConfig(node.connectionId || "")?.read_only) {
+ items.push({ label: "", separator: true });
+ items.push({
+ label: t("hbase.deleteTable"),
+ action: requestDeleteHBaseTable,
+ icon: Trash2,
+ shortcut: shortcutDelete,
+ variant: "destructive" as const,
+ });
+ }
return true;
}
diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarTreeDeleteShortcut.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarTreeDeleteShortcut.spec.ts
new file mode 100644
index 0000000000..3803860c13
--- /dev/null
+++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarTreeDeleteShortcut.spec.ts
@@ -0,0 +1,108 @@
+// @vitest-environment happy-dom
+
+import { describe, expect, it, vi } from "vitest";
+import type { DatabaseType, TreeNode } from "@/types/database";
+import { handleSidebarTreeDeleteShortcut } from "@/lib/sidebar/sidebarTreeDeleteShortcut";
+
+function tableNode(id: string, connectionId: string): TreeNode {
+ return {
+ id,
+ label: id,
+ type: "table",
+ connectionId,
+ database: "default",
+ };
+}
+
+function databaseTypeForNode(node: TreeNode): DatabaseType | undefined {
+ return node.connectionId === "hbase-connection" ? "hbase" : "postgresql";
+}
+
+function deleteEvent(key: "Delete" | "Backspace", init: KeyboardEventInit = {}): KeyboardEvent {
+ return new KeyboardEvent("keydown", { key, cancelable: true, ...init });
+}
+
+describe("sidebar tree delete shortcut", () => {
+ it.each(["Delete", "Backspace"] as const)("routes %s for a single HBase table through REST deletion", (key) => {
+ const activeNode = tableNode("table-1", "hbase-connection");
+ const event = deleteEvent(key);
+ const stopPropagation = vi.spyOn(event, "stopPropagation");
+ const requestHBaseTableDelete = vi.fn(() => true);
+ const requestDefaultDelete = vi.fn(() => true);
+
+ expect(
+ handleSidebarTreeDeleteShortcut(event, {
+ activeNode,
+ selectedNodes: [activeNode],
+ databaseTypeForNode,
+ requestHBaseTableDelete,
+ requestDefaultDelete,
+ }),
+ ).toBe(true);
+ expect(requestHBaseTableDelete).toHaveBeenCalledOnce();
+ expect(requestDefaultDelete).not.toHaveBeenCalled();
+ expect(event.defaultPrevented).toBe(true);
+ expect(stopPropagation).toHaveBeenCalledOnce();
+ });
+
+ it("blocks unsupported HBase multi-selection before generic SQL deletion", () => {
+ const activeNode = tableNode("table-1", "hbase-connection");
+ const selectedNodes = [activeNode, tableNode("table-2", "hbase-connection")];
+ const event = deleteEvent("Delete");
+ const requestHBaseTableDelete = vi.fn(() => true);
+ const requestDefaultDelete = vi.fn(() => true);
+
+ expect(
+ handleSidebarTreeDeleteShortcut(event, {
+ activeNode,
+ selectedNodes,
+ databaseTypeForNode,
+ requestHBaseTableDelete,
+ requestDefaultDelete,
+ }),
+ ).toBe(true);
+ expect(requestHBaseTableDelete).not.toHaveBeenCalled();
+ expect(requestDefaultDelete).not.toHaveBeenCalled();
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it("keeps the existing generic delete route for SQL tables", () => {
+ const activeNode = tableNode("table-1", "postgres-connection");
+ const event = deleteEvent("Delete");
+ const requestHBaseTableDelete = vi.fn(() => true);
+ const requestDefaultDelete = vi.fn(() => true);
+
+ expect(
+ handleSidebarTreeDeleteShortcut(event, {
+ activeNode,
+ selectedNodes: [activeNode],
+ databaseTypeForNode,
+ requestHBaseTableDelete,
+ requestDefaultDelete,
+ }),
+ ).toBe(true);
+ expect(requestHBaseTableDelete).not.toHaveBeenCalled();
+ expect(requestDefaultDelete).toHaveBeenCalledOnce();
+ expect(event.defaultPrevented).toBe(true);
+ });
+
+ it("ignores modified delete shortcuts", () => {
+ const activeNode = tableNode("table-1", "hbase-connection");
+ const event = deleteEvent("Delete", { ctrlKey: true });
+ const requestHBaseTableDelete = vi.fn(() => true);
+ const requestDefaultDelete = vi.fn(() => true);
+
+ expect(
+ handleSidebarTreeDeleteShortcut(event, {
+ activeNode,
+ selectedNodes: [activeNode],
+ databaseTypeForNode,
+ requestHBaseTableDelete,
+ requestDefaultDelete,
+ }),
+ ).toBe(false);
+ expect(requestHBaseTableDelete).not.toHaveBeenCalled();
+ expect(requestDefaultDelete).not.toHaveBeenCalled();
+ expect(event.defaultPrevented).toBe(false);
+ });
+});
diff --git a/apps/desktop/src/lib/hbase/hbaseBrowserPreferences.ts b/apps/desktop/src/lib/hbase/hbaseBrowserPreferences.ts
new file mode 100644
index 0000000000..af4ff45c25
--- /dev/null
+++ b/apps/desktop/src/lib/hbase/hbaseBrowserPreferences.ts
@@ -0,0 +1,20 @@
+import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
+
+export const HBASE_ROW_LIMIT_STORAGE_KEY = "dbx-hbase-row-limit";
+export const DEFAULT_HBASE_ROW_LIMIT = "100";
+export const HBASE_ROW_LIMIT_OPTIONS = ["50", "100", "200", "500"] as const;
+
+export function normalizeHBaseRowLimit(value: unknown): string {
+ const candidate = typeof value === "string" || typeof value === "number" ? String(value) : "";
+ return (HBASE_ROW_LIMIT_OPTIONS as readonly string[]).includes(candidate) ? candidate : DEFAULT_HBASE_ROW_LIMIT;
+}
+
+export function loadHBaseRowLimit(): string {
+ return normalizeHBaseRowLimit(safeLocalStorageGet(HBASE_ROW_LIMIT_STORAGE_KEY));
+}
+
+export function saveHBaseRowLimit(value: unknown): string {
+ const normalized = normalizeHBaseRowLimit(value);
+ safeLocalStorageSet(HBASE_ROW_LIMIT_STORAGE_KEY, normalized);
+ return normalized;
+}
diff --git a/apps/desktop/src/lib/sidebar/sidebarTreeDeleteShortcut.ts b/apps/desktop/src/lib/sidebar/sidebarTreeDeleteShortcut.ts
new file mode 100644
index 0000000000..48028ebd7d
--- /dev/null
+++ b/apps/desktop/src/lib/sidebar/sidebarTreeDeleteShortcut.ts
@@ -0,0 +1,32 @@
+import type { DatabaseType, TreeNode } from "@/types/database";
+
+interface SidebarTreeDeleteShortcutContext {
+ activeNode: TreeNode;
+ selectedNodes: readonly TreeNode[];
+ databaseTypeForNode: (node: TreeNode) => DatabaseType | undefined;
+ requestHBaseTableDelete: () => boolean;
+ requestDefaultDelete: () => boolean;
+}
+
+function isUnmodifiedDeleteShortcut(event: KeyboardEvent): boolean {
+ return !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey && (event.key === "Delete" || event.key === "Backspace");
+}
+
+export function handleSidebarTreeDeleteShortcut(event: KeyboardEvent, context: SidebarTreeDeleteShortcutContext): boolean {
+ if (!isUnmodifiedDeleteShortcut(event)) return false;
+
+ const activeHBaseTable = context.activeNode.type === "table" && context.databaseTypeForNode(context.activeNode) === "hbase";
+ const hasActiveMultiSelection = context.selectedNodes.length > 1 && context.selectedNodes.some((node) => node.id === context.activeNode.id);
+ if (activeHBaseTable && hasActiveMultiSelection) {
+ event.preventDefault();
+ event.stopPropagation();
+ return true;
+ }
+
+ const handled = activeHBaseTable ? context.requestHBaseTableDelete() : context.requestDefaultDelete();
+ if (!handled) return false;
+
+ event.preventDefault();
+ event.stopPropagation();
+ return true;
+}
diff --git a/apps/desktop/src/stores/__tests__/queryStore.database-open.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.database-open.spec.ts
index 28868d822e..85909cc8fa 100644
--- a/apps/desktop/src/stores/__tests__/queryStore.database-open.spec.ts
+++ b/apps/desktop/src/stores/__tests__/queryStore.database-open.spec.ts
@@ -156,6 +156,26 @@ describe("queryStore database open state", () => {
expect(store.tabs.some((tab) => tab.id === structureId)).toBe(true);
});
+ it("closes only the matching HBase table tab after deletion", async () => {
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const store = useQueryStore();
+
+ const deletedTableId = store.createTab("hbase-1", "metrics", "events", "hbase", undefined, "events");
+ const otherTableId = store.createTab("hbase-1", "metrics", "archive", "hbase", undefined, "archive");
+ const otherNamespaceId = store.createTab("hbase-1", "default", "events", "hbase", undefined, "events");
+
+ store.closeDroppedTableObjectTabs({
+ connectionId: "hbase-1",
+ database: "metrics",
+ name: "events",
+ objectType: "TABLE",
+ });
+
+ expect(store.tabs.some((tab) => tab.id === deletedTableId)).toBe(false);
+ expect(store.tabs.some((tab) => tab.id === otherTableId)).toBe(true);
+ expect(store.tabs.some((tab) => tab.id === otherNamespaceId)).toBe(true);
+ });
+
it("matches dropped table schema candidates", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts
index 5c8cf298f6..a0586a0ec9 100644
--- a/apps/desktop/src/stores/queryStore.ts
+++ b/apps/desktop/src/stores/queryStore.ts
@@ -2012,6 +2012,10 @@ export const useQueryStore = defineStore("query", () => {
if (tab.connectionId !== target.connectionId || tab.database !== target.database) return false;
const targetSchemas = droppedTableObjectSchemaCandidates(target);
+ if ((target.objectType ?? "TABLE") === "TABLE" && tab.mode === "hbase") {
+ return tab.sql === target.name;
+ }
+
if (tab.mode === "data") {
const tableMeta = tableMetaForDataTab(tab);
if (!tableMeta || tableMeta.tableName !== target.name) return false;
diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs
index cb42b5af9b..077284751c 100644
--- a/crates/dbx-core/src/storage.rs
+++ b/crates/dbx-core/src/storage.rs
@@ -2067,6 +2067,54 @@ fn persist_connection_in_tx(tx: &rusqlite::Transaction<'_>, config: &ConnectionC
persist_nacos_auth_secrets_in_tx(tx, &config)
}
+fn preserve_unreadable_connections_for_replacement(
+ tx: &rusqlite::Transaction<'_>,
+ replacement_ids: &HashSet,
+) -> Result, String> {
+ let unreadable_rows = {
+ let mut stmt = tx.prepare("SELECT id, config_json FROM connections").map_err(|e| e.to_string())?;
+ let rows = stmt
+ .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))
+ .map_err(|e| e.to_string())?;
+ rows.collect::, _>>()
+ .map_err(|e| e.to_string())?
+ .into_iter()
+ .filter_map(|(id, json)| {
+ serde_json::from_str::(&json).err().map(|error| (id, json, error.to_string()))
+ })
+ .collect::>()
+ };
+
+ tx.execute("DELETE FROM connections", []).map_err(|e| e.to_string())?;
+
+ let mut preserved_ids = Vec::new();
+ for (id, json, error) in unreadable_rows {
+ if replacement_ids.contains(&id) {
+ continue;
+ }
+ warn!("Preserving unreadable saved connection '{}' during connection list update: {}", id, error);
+ tx.execute("INSERT INTO connections (id, config_json) VALUES (?1, ?2)", params![id, json])
+ .map_err(|e| e.to_string())?;
+ preserved_ids.push(id);
+ }
+ Ok(preserved_ids)
+}
+
+fn delete_unreferenced_connection_secrets_in_tx(
+ tx: &rusqlite::Transaction<'_>,
+ retained_ids: &[String],
+) -> Result<(), String> {
+ if retained_ids.is_empty() {
+ tx.execute("DELETE FROM connection_secrets", []).map_err(|e| e.to_string())?;
+ } else {
+ let placeholders = vec!["?"; retained_ids.len()].join(",");
+ let sql = format!("DELETE FROM connection_secrets WHERE connection_id NOT IN ({placeholders})");
+ let ids = retained_ids.iter().map(|id| id as &dyn ToSql);
+ tx.execute(&sql, params_from_iter(ids)).map_err(|e| e.to_string())?;
+ }
+ Ok(())
+}
+
impl Storage {
pub async fn save_connection_metadata_preserving_secrets(
&self,
@@ -2075,7 +2123,8 @@ impl Storage {
let configs = configs.to_vec();
self.with_conn(move |conn| {
let tx = conn.transaction().map_err(|e| e.to_string())?;
- tx.execute("DELETE FROM connections", []).map_err(|e| e.to_string())?;
+ let replacement_ids = configs.iter().map(|config| config.id.clone()).collect::>();
+ let mut retained_ids = preserve_unreadable_connections_for_replacement(&tx, &replacement_ids)?;
for config in &configs {
let config = config.canonicalized();
@@ -2095,14 +2144,8 @@ impl Storage {
.map_err(|e| e.to_string())?;
}
- if configs.is_empty() {
- tx.execute("DELETE FROM connection_secrets", []).map_err(|e| e.to_string())?;
- } else {
- let placeholders = vec!["?"; configs.len()].join(",");
- let sql = format!("DELETE FROM connection_secrets WHERE connection_id NOT IN ({placeholders})");
- let ids = configs.iter().map(|config| &config.id as &dyn ToSql);
- tx.execute(&sql, params_from_iter(ids)).map_err(|e| e.to_string())?;
- }
+ retained_ids.extend(configs.iter().map(|config| config.id.clone()));
+ delete_unreferenced_connection_secrets_in_tx(&tx, &retained_ids)?;
tx.commit().map_err(|e| e.to_string())
})
@@ -2113,20 +2156,15 @@ impl Storage {
let configs = configs.to_vec();
self.with_conn(move |conn| {
let tx = conn.transaction().map_err(|e| e.to_string())?;
- tx.execute("DELETE FROM connections", []).map_err(|e| e.to_string())?;
+ let replacement_ids = configs.iter().map(|config| config.id.clone()).collect::>();
+ let mut retained_ids = preserve_unreadable_connections_for_replacement(&tx, &replacement_ids)?;
for config in &configs {
persist_connection_in_tx(&tx, config)?;
}
- if configs.is_empty() {
- tx.execute("DELETE FROM connection_secrets", []).map_err(|e| e.to_string())?;
- } else {
- let placeholders = vec!["?"; configs.len()].join(",");
- let sql = format!("DELETE FROM connection_secrets WHERE connection_id NOT IN ({placeholders})");
- let ids = configs.iter().map(|config| &config.id as &dyn ToSql);
- tx.execute(&sql, params_from_iter(ids)).map_err(|e| e.to_string())?;
- }
+ retained_ids.extend(configs.iter().map(|config| config.id.clone()));
+ delete_unreferenced_connection_secrets_in_tx(&tx, &retained_ids)?;
tx.commit().map_err(|e| e.to_string())
})
@@ -2199,7 +2237,13 @@ impl Storage {
let mut configs = Vec::new();
for (id, json) in rows {
- let mut config: ConnectionConfig = serde_json::from_str(&json).map_err(|e| e.to_string())?;
+ let mut config: ConnectionConfig = match serde_json::from_str(&json) {
+ Ok(config) => config,
+ Err(error) => {
+ warn!("Skipping unreadable saved connection '{}': {}", id, error);
+ continue;
+ }
+ };
config.password = self.get_secret(&id, "password").await?.unwrap_or_default();
for index in 0..config.transport_layers.len() {
let layer_for_key = config.transport_layers[index].clone();
@@ -4014,6 +4058,55 @@ mod tests {
assert_eq!(mq_token(&loaded[0]), Some("mq-token-secret"));
}
+ #[tokio::test]
+ async fn unreadable_saved_connections_do_not_block_loading_or_get_deleted_by_list_saves() {
+ let path = temp_db_path("unreadable-connection-preservation");
+ let storage = Storage::open(&path).await.unwrap();
+ let mut known = mq_connection("known", "known-secret");
+ storage.save_connections(std::slice::from_ref(&known)).await.unwrap();
+
+ let future_json = serde_json::json!({
+ "id": "future",
+ "name": "Future database",
+ "db_type": "future_database"
+ })
+ .to_string();
+ let inserted_json = future_json.clone();
+ storage
+ .with_conn(move |conn| {
+ let tx = conn.transaction().map_err(|e| e.to_string())?;
+ tx.execute(
+ "INSERT INTO connections (id, config_json) VALUES (?1, ?2)",
+ rusqlite::params!["future", inserted_json],
+ )
+ .map_err(|e| e.to_string())?;
+ tx.execute(
+ "INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?1, ?2, ?3)",
+ rusqlite::params!["future", "password", "future-secret"],
+ )
+ .map_err(|e| e.to_string())?;
+ tx.commit().map_err(|e| e.to_string())
+ })
+ .await
+ .unwrap();
+
+ let loaded = storage.load_connections().await.unwrap();
+ assert_eq!(loaded.len(), 1);
+ assert_eq!(loaded[0].id, "known");
+
+ known.name = "Known updated".to_string();
+ storage.save_connection_metadata_preserving_secrets(std::slice::from_ref(&known)).await.unwrap();
+ assert_eq!(raw_connection_json(&storage, "future").await, future_json);
+ assert_eq!(storage.get_secret("future", "password").await.unwrap().as_deref(), Some("future-secret"));
+
+ storage.save_connections(&[]).await.unwrap();
+ assert!(storage.load_connections().await.unwrap().is_empty());
+ assert_eq!(raw_connection_json(&storage, "future").await, future_json);
+ assert_eq!(storage.get_secret("future", "password").await.unwrap().as_deref(), Some("future-secret"));
+
+ let _ = std::fs::remove_file(path);
+ }
+
#[tokio::test]
async fn metadata_save_scrubs_mq_auth_token_and_preserves_existing_secret() {
let path = temp_db_path("mq-token-metadata");
diff --git a/packages/app-tests/hbaseBrowserRefresh.test.ts b/packages/app-tests/hbaseBrowserRefresh.test.ts
new file mode 100644
index 0000000000..53e28342c8
--- /dev/null
+++ b/packages/app-tests/hbaseBrowserRefresh.test.ts
@@ -0,0 +1,70 @@
+import { strict as assert } from "node:assert";
+import { readFileSync } from "node:fs";
+import { test } from "vitest";
+import { DEFAULT_HBASE_ROW_LIMIT, HBASE_ROW_LIMIT_STORAGE_KEY, loadHBaseRowLimit, normalizeHBaseRowLimit, saveHBaseRowLimit } from "../../apps/desktop/src/lib/hbase/hbaseBrowserPreferences.ts";
+
+function functionBody(source: string, name: string): string {
+ const signatureIndex = source.indexOf(`async function ${name}(`);
+ assert.notEqual(signatureIndex, -1, `Could not find function ${name}`);
+ const bodyStart = source.indexOf("{", signatureIndex);
+ let depth = 0;
+ for (let index = bodyStart; index < source.length; index += 1) {
+ if (source[index] === "{") depth += 1;
+ if (source[index] === "}") {
+ depth -= 1;
+ if (depth === 0) return source.slice(bodyStart + 1, index);
+ }
+ }
+ throw new Error(`Could not parse function ${name}`);
+}
+
+test("HBase table DDL refreshes the visible object list instead of reusing loaded children", () => {
+ const source = readFileSync("apps/desktop/src/components/hbase/HBaseBrowser.vue", "utf8");
+
+ for (const name of ["createTable", "deleteTable"]) {
+ const body = functionBody(source, name);
+ assert.match(body, /await connectionStore\.refreshObjectListTreeNode\(props\.connectionId, props\.namespace\)/);
+ assert.doesNotMatch(body, /connectionStore\.loadTables/);
+ }
+});
+
+test("HBase grid disables unsupported offset and infinite-scroll pagination", () => {
+ const source = readFileSync("apps/desktop/src/components/hbase/HBaseBrowser.vue", "utf8");
+ assert.match(source, / {
+ const source = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
+ const menuStart = source.indexOf('if (currentDatabaseType() === "hbase" && node.type === "table")');
+ const menuEnd = source.indexOf("return true;", menuStart);
+ const menu = source.slice(menuStart, menuEnd);
+
+ assert.match(menu, /label: t\("hbase\.deleteTable"\)/);
+ assert.match(menu, /action: requestDeleteHBaseTable/);
+ assert.match(source, /await api\.hbaseDeleteTable\(node\.connectionId, node\.database, node\.label\)/);
+ assert.match(source, /await connectionStore\.refreshObjectListTreeNode\(node\.connectionId, node\.database\)/);
+});
+
+test("HBase row scan limit is normalized and persisted", () => {
+ const previousDescriptor = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
+ const values = new Map();
+ Object.defineProperty(globalThis, "localStorage", {
+ configurable: true,
+ value: {
+ getItem: (key: string) => values.get(key) ?? null,
+ setItem: (key: string, value: string) => values.set(key, value),
+ },
+ });
+
+ try {
+ assert.equal(loadHBaseRowLimit(), DEFAULT_HBASE_ROW_LIMIT);
+ assert.equal(normalizeHBaseRowLimit("500"), "500");
+ assert.equal(normalizeHBaseRowLimit("999"), DEFAULT_HBASE_ROW_LIMIT);
+ assert.equal(saveHBaseRowLimit("200"), "200");
+ assert.equal(values.get(HBASE_ROW_LIMIT_STORAGE_KEY), "200");
+ assert.equal(loadHBaseRowLimit(), "200");
+ } finally {
+ if (previousDescriptor) Object.defineProperty(globalThis, "localStorage", previousDescriptor);
+ else delete (globalThis as { localStorage?: Storage }).localStorage;
+ }
+});
diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs
index 83a5a36226..8a72fe70b4 100644
--- a/src-tauri/src/commands/connection.rs
+++ b/src-tauri/src/commands/connection.rs
@@ -920,6 +920,18 @@ async fn test_connection_with_info_inner(
.await
.map(|_| "Connection successful".to_string())
}
+ DatabaseType::Hbase => {
+ let client = db::hbase_driver::HBaseClient::new(
+ &url,
+ Some(&config.username),
+ Some(&config.password),
+ false,
+ connect_timeout,
+ )?;
+ db::hbase_driver::test_connection(&client, connect_timeout)
+ .await
+ .map(|_| "Connection successful".to_string())
+ }
DatabaseType::Qdrant | DatabaseType::Milvus | DatabaseType::Weaviate | DatabaseType::ChromaDb => {
let kind = match config.db_type {
DatabaseType::Qdrant => db::vector_driver::VectorDbKind::Qdrant,
@@ -1241,6 +1253,17 @@ pub async fn connect_db(
db::elasticsearch_driver::test_connection(&mut client, connect_timeout).await?;
PoolKind::Elasticsearch(client)
}
+ DatabaseType::Hbase => {
+ let client = db::hbase_driver::HBaseClient::new(
+ &url,
+ Some(&db_config.username),
+ Some(&db_config.password),
+ false,
+ connect_timeout,
+ )?;
+ db::hbase_driver::test_connection(&client, connect_timeout).await?;
+ PoolKind::HBase(client)
+ }
DatabaseType::Qdrant | DatabaseType::Milvus | DatabaseType::Weaviate | DatabaseType::ChromaDb => {
let kind = match db_config.db_type {
DatabaseType::Qdrant => db::vector_driver::VectorDbKind::Qdrant,