diff --git a/desktop/src/components/db-viewer/ObjectExplorerPage.tsx b/desktop/src/components/db-viewer/ObjectExplorerPage.tsx index aa52cd5..525610e 100644 --- a/desktop/src/components/db-viewer/ObjectExplorerPage.tsx +++ b/desktop/src/components/db-viewer/ObjectExplorerPage.tsx @@ -22,10 +22,6 @@ interface ObjectExplorerPageProps { sidebarMode?: boolean; } -const OBJECT_TYPE_OPTIONS = (Object.keys(TYPE_LABELS) as ObjectType[]).map( - (t) => ({ value: t, label: TYPE_LABELS[t] }), -); - /** Natural plural for empty-state copy, derived from SINGULAR_LABELS with known irregulars mapped explicitly. */ function emptyPlural(type: ObjectType): string { const singular = SINGULAR_LABELS[type]; @@ -75,6 +71,7 @@ function typeToDdlType(type: ObjectType): string { sequences: "sequence", enums: "enum", extensions: "extension", + hypertables: "table", indexes: "index", constraints: "constraint", roles: "role", @@ -125,6 +122,19 @@ export function ObjectExplorerPage({ const changesQueue = useDbViewerStore((s) => s.changesQueue); const openObjectTab = useDbViewerStore((s) => s.openObjectTab); const refreshTree = useDbViewerStore((s) => s.refreshTree); + const hypertablesAvailable = useDbViewerStore((s) => s.hypertablesAvailable); + const setHypertablesAvailable = useDbViewerStore((s) => s.setHypertablesAvailable); + // Build the dropdown from the full object-type union, gating hypertables + // on confirmed TimescaleDB availability (visibleObjectTypes in ObjectTree + // is scoped to that tree's smaller type set). + const objectTypeOptions = useMemo(() => { + const all = Object.keys(TYPE_LABELS) as ObjectType[]; + const visible = + hypertablesAvailable === true + ? all + : all.filter((t) => t !== "hypertables"); + return visible.map((t) => ({ value: t, label: TYPE_LABELS[t] })); + }, [hypertablesAvailable]); // Use store for persistence, but allow re-fetching when schema changes const [items, setItems] = useState(null); @@ -218,6 +228,23 @@ export function ObjectExplorerPage({ } }, [currentSchema, fetch]); + // Probe TimescaleDB availability once per connection so the hypertables + // object type only appears in the dropdown when it is actually supported. + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await cmd.getHypertables(connectionId); + if (!cancelled) setHypertablesAvailable(res.available); + } catch { + if (!cancelled) setHypertablesAvailable(false); + } + })(); + return () => { + cancelled = true; + }; + }, [connectionId, setHypertablesAvailable]); + // After a commit containing ddl items succeeds, refetch the current // object-type list + refresh the schema tree so newly created/dropped // objects show up without a manual refresh. @@ -325,7 +352,7 @@ export function ObjectExplorerPage({ handleTypeChange(v as ObjectType)} - options={OBJECT_TYPE_OPTIONS} + options={objectTypeOptions} variant="ghost" aria-label="Object type" /> diff --git a/desktop/src/components/db-viewer/ObjectTree.test.tsx b/desktop/src/components/db-viewer/ObjectTree.test.tsx new file mode 100644 index 0000000..83cf3e5 --- /dev/null +++ b/desktop/src/components/db-viewer/ObjectTree.test.tsx @@ -0,0 +1,17 @@ +import { describe, it, expect } from "vitest"; +import { TYPE_LABELS, visibleObjectTypes } from "./ObjectTree"; + +describe("hypertables object type", () => { + it("registers label and stays in the type union", () => { + expect(TYPE_LABELS.hypertables).toBe("hypertables"); + expect(Object.keys(TYPE_LABELS)).toContain("hypertables"); + }); + + it("hides hypertables until availability is confirmed true", () => { + const base = Object.keys(TYPE_LABELS) as ReturnType; + expect(visibleObjectTypes(null)).not.toContain("hypertables"); + expect(visibleObjectTypes(false)).not.toContain("hypertables"); + expect(visibleObjectTypes(true)).toContain("hypertables"); + expect(visibleObjectTypes(true).length).toBe(base.length); + }); +}); diff --git a/desktop/src/components/db-viewer/ObjectTree.tsx b/desktop/src/components/db-viewer/ObjectTree.tsx index 5bbeb4d..a70a168 100644 --- a/desktop/src/components/db-viewer/ObjectTree.tsx +++ b/desktop/src/components/db-viewer/ObjectTree.tsx @@ -1,22 +1,23 @@ import { useEffect, useState, useMemo } from "react"; -import { ChevronRight, ChevronDown, FunctionSquare, GitBranch, ListOrdered, Tag, Puzzle, Search } from "lucide-react"; +import { ChevronRight, ChevronDown, FunctionSquare, GitBranch, ListOrdered, Tag, Puzzle, Layers, Search } from "lucide-react"; import { useDbViewerStore } from "../../stores/dbViewerStore"; import * as cmd from "../../lib/commands"; -import type { FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo } from "../../lib/types"; +import type { FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, HypertableInfo } from "../../lib/types"; -type ObjectType = "functions" | "triggers" | "sequences" | "enums" | "extensions"; +type ObjectType = "functions" | "triggers" | "sequences" | "enums" | "extensions" | "hypertables"; interface ObjectTreeProps { type: ObjectType; connectionId: string; } -const TYPE_LABELS: Record = { +export const TYPE_LABELS: Record = { functions: "functions", triggers: "triggers", sequences: "sequences", enums: "enums", extensions: "extensions", + hypertables: "hypertables", }; const ICONS: Record = { @@ -25,8 +26,18 @@ const ICONS: Record = { sequences: , enums: , extensions: , + hypertables: , }; +/** Object types visible in the dropdown. Hypertables only show when the + * connection confirmed TimescaleDB availability (available === true). */ +export function visibleObjectTypes(hypertablesAvailable: boolean | null): ObjectType[] { + const all = Object.keys(TYPE_LABELS) as ObjectType[]; + return hypertablesAvailable === true + ? all + : all.filter((t) => t !== "hypertables"); +} + function SourceCode({ source }: { source: string }) { const [expanded, setExpanded] = useState(false); const maxLen = 500; @@ -59,11 +70,13 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) { const sequences = useDbViewerStore((s) => s.sequences); const enums = useDbViewerStore((s) => s.enums); const extensions = useDbViewerStore((s) => s.extensions); + const hypertables = useDbViewerStore((s) => s.hypertables); const setFunctions = useDbViewerStore((s) => s.setFunctions); const setTriggers = useDbViewerStore((s) => s.setTriggers); const setSequences = useDbViewerStore((s) => s.setSequences); const setEnums = useDbViewerStore((s) => s.setEnums); const setExtensions = useDbViewerStore((s) => s.setExtensions); + const setHypertables = useDbViewerStore((s) => s.setHypertables); const [loading, setLoading] = useState(false); const [expandedKeys, setExpandedKeys] = useState>(new Set()); @@ -77,8 +90,9 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) { case "sequences": return sequences; case "enums": return enums; case "extensions": return extensions; + case "hypertables": return hypertables; } - }, [type, functions, triggers, sequences, enums, extensions]); + }, [type, functions, triggers, sequences, enums, extensions, hypertables]); const setter = useMemo(() => { switch (type) { @@ -87,8 +101,9 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) { case "sequences": return setSequences; case "enums": return setEnums; case "extensions": return setExtensions; + case "hypertables": return setHypertables; } - }, [type, setFunctions, setTriggers, setSequences, setEnums, setExtensions]); + }, [type, setFunctions, setTriggers, setSequences, setEnums, setExtensions, setHypertables]); // Fetch on mount if not in store useEffect(() => { @@ -113,6 +128,9 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) { } else if (type === "enums") { const result = await cmd.getEnums(connectionId, currentSchema ?? undefined); if (!cancelled) (setEnums as (v: EnumInfo[]) => void)(result); + } else if (type === "hypertables") { + const res = await cmd.getHypertables(connectionId, currentSchema ?? undefined); + if (!cancelled) (setHypertables as (v: HypertableInfo[]) => void)(res.available ? res.items : []); } } catch { // Silently fail — store remains null, we show the empty state @@ -123,7 +141,7 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) { fetchData(); return () => { cancelled = true; }; - }, [type, connectionId, currentSchema, data, setter, setFunctions, setTriggers, setSequences, setEnums, setExtensions]); + }, [type, connectionId, currentSchema, data, setter, setFunctions, setTriggers, setSequences, setEnums, setExtensions, setHypertables]); const q = search.toLowerCase().trim(); diff --git a/desktop/src/components/db-viewer/objects/ObjectDetail.tsx b/desktop/src/components/db-viewer/objects/ObjectDetail.tsx index c929f2c..2d6577f 100644 --- a/desktop/src/components/db-viewer/objects/ObjectDetail.tsx +++ b/desktop/src/components/db-viewer/objects/ObjectDetail.tsx @@ -6,6 +6,7 @@ import { ListChecks, ListOrdered, Puzzle, + Layers, ShieldCheck, SquareFunction, Tag, @@ -29,6 +30,7 @@ export const TYPE_LABELS: Record = { sequences: "Sequences", enums: "Enums", extensions: "Extensions", + hypertables: "Hypertables", indexes: "Indexes", constraints: "Constraints", procedures: "Procedures", @@ -41,6 +43,7 @@ export const SINGULAR_LABELS: Record = { sequences: "sequence", enums: "enum", extensions: "extension", + hypertables: "hypertable", indexes: "index", constraints: "constraint", procedures: "procedure", @@ -55,6 +58,7 @@ export const OBJECT_ICONS: Record = { sequences: , enums: , extensions: , + hypertables: , indexes: , constraints: , procedures: , diff --git a/desktop/src/lib/commands.ts b/desktop/src/lib/commands.ts index 06b821c..d24d942 100644 --- a/desktop/src/lib/commands.ts +++ b/desktop/src/lib/commands.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, MySqlToolStatus, MySqlBackupOptions, MySqlRestoreOptions, SqliteBackupOptions, SqliteRestoreOptions, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo, RoleInfo, PrivilegeEntry, RebuildReadiness, MaintenanceResult, TablespaceInfo, ColumnInfo } from "./types"; +import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, MySqlToolStatus, MySqlBackupOptions, MySqlRestoreOptions, SqliteBackupOptions, SqliteRestoreOptions, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo, RoleInfo, PrivilegeEntry, RebuildReadiness, MaintenanceResult, TablespaceInfo, ColumnInfo, HypertableListResponse } from "./types"; import type { FilterRule, SortRule } from "../stores/dbViewerStore"; import type { ChangePayload } from "./changePayload"; import { buildObjectDdl as buildObjectDdlImpl, type ObjectKind, type DdlParams } from "./objectCrud"; @@ -226,6 +226,13 @@ export async function getExtensions(connectionId: string): Promise("get_extensions", { connectionId }); } +export async function getHypertables( + connectionId: string, + schema?: string, +): Promise { + return invoke("get_hypertables", { connectionId, schema }); +} + export async function getSchemaGraph( connectionId: string, schema?: string, diff --git a/desktop/src/lib/types.ts b/desktop/src/lib/types.ts index 721472a..fee94ab 100644 --- a/desktop/src/lib/types.ts +++ b/desktop/src/lib/types.ts @@ -367,6 +367,7 @@ export type ObjectType = | "sequences" | "enums" | "extensions" + | "hypertables" | "indexes" | "constraints" | "procedures" @@ -378,6 +379,21 @@ export interface ObjectSearchHit { object_type: string; // TABLE | VIEW | MATERIALIZED VIEW | FUNCTION | PROCEDURE | TRIGGER | SEQUENCE | ENUM | EXTENSION | INDEX | CONSTRAINT } +export interface HypertableInfo { + name: string; + schema: string; + num_dimensions: number; + compression_enabled: boolean; + num_chunks: number; + total_size_bytes: number | null; +} + +export interface HypertableListResponse { + available: boolean; + reason: string | null; + items: HypertableInfo[]; +} + export interface DependencyInfo { deptype: string; class: string; diff --git a/desktop/src/stores/dbViewerStore.ts b/desktop/src/stores/dbViewerStore.ts index 70d6fda..6ea9732 100644 --- a/desktop/src/stores/dbViewerStore.ts +++ b/desktop/src/stores/dbViewerStore.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, IndexInfo, ConstraintInfo, ObjectType, RoleInfo } from "../lib/types"; +import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, HypertableInfo, IndexInfo, ConstraintInfo, ObjectType, RoleInfo } from "../lib/types"; import { getDatabases, getSchemas, getTables } from "../lib/commands"; import type { ObjectKind, DdlParams } from "../lib/objectCrud"; @@ -117,6 +117,8 @@ interface DbViewerState { sequences: SequenceInfo[] | null; enums: EnumInfo[] | null; extensions: ExtensionInfo[] | null; + hypertables: HypertableInfo[] | null; + hypertablesAvailable: boolean | null; indexes: IndexInfo[] | null; setSchemaTreeLoading: (loading: boolean) => void; constraints: ConstraintInfo[] | null; @@ -182,6 +184,8 @@ interface DbViewerState { setSequences: (sequences: SequenceInfo[]) => void; setEnums: (enums: EnumInfo[]) => void; setExtensions: (extensions: ExtensionInfo[]) => void; + setHypertables: (hypertables: HypertableInfo[]) => void; + setHypertablesAvailable: (v: boolean | null) => void; setIndexes: (indexes: IndexInfo[]) => void; setConstraints: (constraints: ConstraintInfo[]) => void; setRoles: (roles: RoleInfo[]) => void; @@ -224,6 +228,8 @@ const initialState = { sequences: null as SequenceInfo[] | null, enums: null as EnumInfo[] | null, extensions: null as ExtensionInfo[] | null, + hypertables: null as HypertableInfo[] | null, + hypertablesAvailable: null as boolean | null, indexes: null as IndexInfo[] | null, constraints: null as ConstraintInfo[] | null, roles: [] as RoleInfo[], @@ -586,6 +592,8 @@ export const useDbViewerStore = create((set, get) => ({ setSequences: (sequences) => set({ sequences }), setEnums: (enums) => set({ enums }), setExtensions: (extensions) => set({ extensions }), + setHypertables: (hypertables) => set({ hypertables }), + setHypertablesAvailable: (v) => set({ hypertablesAvailable: v }), setIndexes: (indexes) => set({ indexes }), setConstraints: (constraints) => set({ constraints }), setRoles: (roles) => set({ roles }),