diff --git a/agents/drivers/kingbase-go/integration_test.go b/agents/drivers/kingbase-go/integration_test.go index abeaecbb67..dd8a28344f 100644 --- a/agents/drivers/kingbase-go/integration_test.go +++ b/agents/drivers/kingbase-go/integration_test.go @@ -100,6 +100,10 @@ func TestKingbaseIntegration(t *testing.T) { if err != nil || !strings.Contains(fmt.Sprint(source["source"]), function) { t.Fatalf("get function source failed: source=%v err=%v", source, err) } + viewSource, err := server.getObjectSource("public", view, "VIEW") + if err != nil || !strings.Contains(fmt.Sprint(viewSource["source"]), parent) { + t.Fatalf("get view source failed: source=%v err=%v", viewSource, err) + } transactionParams := map[string]json.RawMessage{ "schema": rawJSON("public"), diff --git a/agents/drivers/kingbase-go/kingbase_metadata.go b/agents/drivers/kingbase-go/kingbase_metadata.go index d8fa77f6ee..b78a2388c4 100644 --- a/agents/drivers/kingbase-go/kingbase_metadata.go +++ b/agents/drivers/kingbase-go/kingbase_metadata.go @@ -741,17 +741,35 @@ func (s *server) getObjectSource(schema, name, objectType string) (map[string]an catalog, prefix, function := "sys_catalog", "sys", "sys_get_viewdef" if s.mode.postgresCatalog { catalog, prefix, function = "pg_catalog", "pg", "pg_get_viewdef" + } else if s.usePgViewDefinition { + function = "pg_get_viewdef" + } + querySource := func(definitionFunction string) error { + query := fmt.Sprintf("SELECT %s(c.oid) FROM %s.%s_class c JOIN %s.%s_namespace n ON n.oid=c.relnamespace WHERE n.nspname=%s AND c.relname=%s LIMIT 1", definitionFunction, catalog, prefix, catalog, prefix, quoteLiteral(effective), quoteLiteral(name)) + return s.requireDBQueryRow(query, &source) + } + err = querySource(function) + if err != nil && function == "sys_get_viewdef" && isUndefinedFunction(err, function) { + s.usePgViewDefinition = true + err = querySource("pg_get_viewdef") } - query := fmt.Sprintf("SELECT %s(c.oid) FROM %s.%s_class c JOIN %s.%s_namespace n ON n.oid=c.relnamespace WHERE n.nspname=%s AND c.relname=%s LIMIT 1", function, catalog, prefix, catalog, prefix, quoteLiteral(effective), quoteLiteral(name)) - err = s.requireDBQueryRow(query, &source) } } else if kind == "FUNCTION" || kind == "PROCEDURE" { catalog, prefix, function := "sys_catalog", "sys", "sys_get_functiondef" if s.mode.postgresCatalog { catalog, prefix, function = "pg_catalog", "pg", "pg_get_functiondef" + } else if s.usePgFunctionDefinition { + function = "pg_get_functiondef" + } + querySource := func(definitionFunction string) error { + query := fmt.Sprintf("SELECT %s(p.oid) FROM %s.%s_proc p JOIN %s.%s_namespace n ON n.oid=p.pronamespace WHERE n.nspname=%s AND p.proname=%s ORDER BY CASE WHEN p.prorettype=2278 THEN 0 ELSE 1 END LIMIT 1", definitionFunction, catalog, prefix, catalog, prefix, quoteLiteral(effective), quoteLiteral(name)) + return s.requireDBQueryRow(query, &source) + } + err = querySource(function) + if err != nil && function == "sys_get_functiondef" && isUndefinedFunction(err, function) { + s.usePgFunctionDefinition = true + err = querySource("pg_get_functiondef") } - query := fmt.Sprintf("SELECT %s(p.oid) FROM %s.%s_proc p JOIN %s.%s_namespace n ON n.oid=p.pronamespace WHERE n.nspname=%s AND p.proname=%s ORDER BY CASE WHEN p.prorettype=2278 THEN 0 ELSE 1 END LIMIT 1", function, catalog, prefix, catalog, prefix, quoteLiteral(effective), quoteLiteral(name)) - err = s.requireDBQueryRow(query, &source) } if err != nil && err != sql.ErrNoRows { return nil, err diff --git a/agents/drivers/kingbase-go/main.go b/agents/drivers/kingbase-go/main.go index 510d62f9af..23d45fb609 100644 --- a/agents/drivers/kingbase-go/main.go +++ b/agents/drivers/kingbase-go/main.go @@ -134,6 +134,8 @@ type server struct { params connectParams mode kingbaseMode usePgDefaultExpression bool + usePgViewDefinition bool + usePgFunctionDefinition bool catalogIdentityUnsupported bool infoColumnTypeUnsupported bool infoUdtNameUnsupported bool @@ -456,6 +458,8 @@ func (s *server) connect(cp connectParams) error { s.params = cp s.mode = detectKingbaseMode(db, cp.MySQLCompatMode) s.usePgDefaultExpression = false + s.usePgViewDefinition = false + s.usePgFunctionDefinition = false s.catalogIdentityUnsupported = false s.infoColumnTypeUnsupported = false s.infoUdtNameUnsupported = false @@ -520,6 +524,8 @@ func (s *server) disconnect() error { s.cancelActiveQuery() s.closeAllQuerySessions() s.usePgDefaultExpression = false + s.usePgViewDefinition = false + s.usePgFunctionDefinition = false s.catalogIdentityUnsupported = false s.infoColumnTypeUnsupported = false s.infoUdtNameUnsupported = false diff --git a/agents/drivers/kingbase-go/main_test.go b/agents/drivers/kingbase-go/main_test.go index d1ce5339eb..740307bc3e 100644 --- a/agents/drivers/kingbase-go/main_test.go +++ b/agents/drivers/kingbase-go/main_test.go @@ -1142,6 +1142,98 @@ func TestRoutineSourceUsesKingbaseCatalogFunction(t *testing.T) { } } +func TestObjectSourceFallsBackToPgDefinitionFunctionsAndCachesChoice(t *testing.T) { + tests := []struct { + name string + objectType string + objectName string + sysFunction string + pgFunction string + catalogTable string + source string + }{ + { + name: "function", + objectType: "FUNCTION", + objectName: "format_name", + sysFunction: "sys_get_functiondef", + pgFunction: "pg_get_functiondef", + catalogTable: "sys_catalog.sys_proc", + source: "CREATE FUNCTION public.format_name() RETURNS text AS $$ SELECT 'x'; $$", + }, + { + name: "view", + objectType: "VIEW", + objectName: "active_orders", + sysFunction: "sys_get_viewdef", + pgFunction: "pg_get_viewdef", + catalogTable: "sys_catalog.sys_class", + source: "SELECT * FROM public.orders WHERE active", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + state := &metadataDriverState{query: func(query string) (driver.Rows, error) { + if !strings.Contains(query, test.catalogTable) { + return nil, errors.New("unexpected query: " + query) + } + if strings.Contains(query, test.sysFunction+"(") { + return nil, &gokb.Error{Code: gokb.ErrorCode("42883"), Message: "function " + test.sysFunction + "(oid) does not exist"} + } + if strings.Contains(query, test.pgFunction+"(") { + return &valueRows{columns: []string{"source"}, rows: [][]driver.Value{{test.source}}}, nil + } + return nil, errors.New("unexpected query: " + query) + }} + server := newServer() + server.db = openMetadataDB(t, state) + + for call := 0; call < 2; call++ { + source, err := server.getObjectSource("public", test.objectName, test.objectType) + if err != nil { + t.Fatal(err) + } + if source["source"] != test.source { + t.Fatalf("unexpected source: %#v", source) + } + } + + state.mu.Lock() + queries := append([]string(nil), state.queries...) + state.mu.Unlock() + if len(queries) != 3 || + !strings.Contains(queries[0], test.sysFunction+"(") || + !strings.Contains(queries[1], test.pgFunction+"(") || + !strings.Contains(queries[2], test.pgFunction+"(") { + t.Fatalf("definition fallback choice was not cached: %v", queries) + } + }) + } +} + +func TestObjectSourceDoesNotFallbackOnUnrelatedErrors(t *testing.T) { + permissionErr := errors.New("permission denied for sys_proc") + state := &metadataDriverState{query: func(query string) (driver.Rows, error) { + if strings.Contains(query, "sys_get_functiondef(") { + return nil, permissionErr + } + return nil, errors.New("unexpected fallback query: " + query) + }} + server := newServer() + server.db = openMetadataDB(t, state) + + _, err := server.getObjectSource("public", "format_name", "FUNCTION") + if !errors.Is(err, permissionErr) { + t.Fatalf("unexpected error: %v", err) + } + state.mu.Lock() + defer state.mu.Unlock() + if len(state.queries) != 1 || !strings.Contains(state.queries[0], "sys_get_functiondef(") { + t.Fatalf("unrelated error triggered a fallback: %v", state.queries) + } +} + func TestColumnsFallbackToPgGetExprAndCacheChoice(t *testing.T) { registerExpressionFallbackDriver.Do(func() { sql.Register("kingbase-expression-fallback-test", fallbackDriver{}) }) state := &fallbackDriverState{} diff --git a/apps/desktop/src/components/objects/ExtensionDetailsDialog.vue b/apps/desktop/src/components/objects/ExtensionDetailsDialog.vue new file mode 100644 index 0000000000..aea88122c0 --- /dev/null +++ b/apps/desktop/src/components/objects/ExtensionDetailsDialog.vue @@ -0,0 +1,67 @@ + + + + + + + + + {{ t("extension.detailsTitle") }} + + + + + + {{ t("extension.name") }} + {{ extension.name }} + + + {{ t("connection.version") }} + {{ extension.version }} + + + Schema + {{ extension.schema || "-" }} + + + {{ t("structureEditor.comment") }} + {{ extension.comment || "-" }} + + + + + {{ t("common.close") }} + + + + diff --git a/apps/desktop/src/components/objects/__tests__/ExtensionDetailsDialog.spec.ts b/apps/desktop/src/components/objects/__tests__/ExtensionDetailsDialog.spec.ts new file mode 100644 index 0000000000..6d323a45c7 --- /dev/null +++ b/apps/desktop/src/components/objects/__tests__/ExtensionDetailsDialog.spec.ts @@ -0,0 +1,80 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, nextTick, ref, type App } from "vue"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import i18n from "@/i18n"; +import type { TreeNode } from "@/types/database"; + +vi.mock("@/components/ui/dialog", async () => { + const { defineComponent, h } = await import("vue"); + const passthrough = defineComponent({ + setup(_props, { slots }) { + return () => h("div", slots.default?.()); + }, + }); + const dialog = defineComponent({ + props: { open: Boolean }, + setup(props, { slots }) { + return () => (props.open ? h("div", slots.default?.()) : null); + }, + }); + return { Dialog: dialog, DialogContent: passthrough, DialogFooter: passthrough, DialogHeader: passthrough, DialogTitle: passthrough }; +}); + +vi.mock("@/components/ui/button", async () => { + const { defineComponent, h } = await import("vue"); + return { + Button: defineComponent({ + setup(_props, { slots }) { + return () => h("button", slots.default?.()); + }, + }), + }; +}); + +import ExtensionDetailsDialog from "@/components/objects/ExtensionDetailsDialog.vue"; + +const mountedApps: App[] = []; + +afterEach(() => { + for (const app of mountedApps.splice(0)) app.unmount(); + document.body.innerHTML = ""; +}); + +describe("ExtensionDetailsDialog", () => { + it("shows metadata already loaded for the extension tree node", async () => { + const node: TreeNode = { + id: "connection:database:public:sys_stat_statements", + label: "sys_stat_statements", + type: "extension", + schema: "public", + meta: { + name: "sys_stat_statements", + version: "1.10", + schema: "public", + comment: "track planning and execution statistics", + }, + }; + const dialog = ref | null>(null); + const container = document.createElement("div"); + document.body.append(container); + const app = createApp( + defineComponent({ + setup() { + return () => h(ExtensionDetailsDialog, { ref: dialog, node }); + }, + }), + ); + mountedApps.push(app); + app.use(i18n); + app.mount(container); + dialog.value?.show(); + await nextTick(); + + const text = document.body.textContent || ""; + expect(text).toContain("sys_stat_statements"); + expect(text).toContain("1.10"); + expect(text).toContain("public"); + expect(text).toContain("track planning and execution statistics"); + }); +}); diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index a6db12c61a..030a850246 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -29,6 +29,7 @@ import TreeItem from "./TreeItem.vue"; import SidebarTreeRuntimeHost from "./SidebarTreeRuntimeHost.vue"; import SidebarTreeItemDialogs from "./SidebarTreeItemDialogs.vue"; import InstallExtensionDialog from "@/components/objects/InstallExtensionDialog.vue"; +import ExtensionDetailsDialog from "@/components/objects/ExtensionDetailsDialog.vue"; import { RecycleScroller } from "vue-virtual-scroller"; import "vue-virtual-scroller/dist/vue-virtual-scroller.css"; import LightDropdown from "@/components/ui/LightDropdown.vue"; @@ -79,6 +80,8 @@ const sidebarDangerDialogConfirming = ref(false); const sidebarTreeItemDialogController = ref | null>(null); const sidebarInstallExtensionTarget = ref(null); const sidebarInstallExtensionDialogRef = ref | null>(null); +const sidebarExtensionDetailsTarget = ref(null); +const sidebarExtensionDetailsDialogRef = ref | null>(null); const sidebarTreeRuntimeHostRef = ref(null); const sidebarTreeRuntime = createSidebarTreeRuntime(); const sidebarTreeRuntimeInitialNode: TreeNode = { id: "__sidebar-runtime__", label: "", type: "connection-group" }; @@ -1284,6 +1287,12 @@ async function openSidebarInstallExtension(node: TreeNode) { sidebarInstallExtensionDialogRef.value?.show(); } +async function openSidebarExtensionDetails(node: TreeNode) { + sidebarExtensionDetailsTarget.value = createSidebarActionTarget(node); + await nextTick(); + sidebarExtensionDetailsDialogRef.value?.show(); +} + function beginSidebarAction(): number { sidebarActionGeneration += 1; sidebarDdlOpen.value = false; @@ -1751,6 +1760,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes }); @open-danger-dialog="openSidebarDangerDialog" @open-dialog-controller="updateSidebarTreeItemDialogController" @open-install-extension="openSidebarInstallExtension" + @open-extension-details="openSidebarExtensionDetails" /> @@ -2056,6 +2066,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes }); + {{ t("sidebar.noConnections") }} diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index eaa5dc2cd1..5e61a2346f 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -342,6 +342,7 @@ const emit = defineEmits<{ "open-danger-dialog": [request: SidebarDangerDialogRequest]; "open-dialog-controller": [controller: Record | null]; "open-install-extension": [node: TreeNode]; + "open-extension-details": [node: TreeNode]; }>(); const { @@ -760,6 +761,8 @@ function runRowClickAction(clickDetail: number) { scheduleOpenData(node); } else if (action === "open-source") { openObjectSourceDialog(false); + } else if (action === "open-extension-details") { + emit("open-extension-details", node); } else if (isDocumentBrowserTreeNode(node.type)) { openMongoTreeData(node); } else if (action === "toggle") { @@ -1067,6 +1070,8 @@ function onDoubleClick(event: MouseEvent) { activateDataTableFromDoubleClick(); } else if (action === "open-source") { openObjectSourceDialog(false); + } else if (action === "open-extension-details") { + emit("open-extension-details", activeNode.value); } else if (action === "open-saved-sql") { openSavedSqlFile(); } else if (action === "toggle" && (activeNode.value.type === "mongo-gridfs" || isDocumentBrowserTreeNode(activeNode.value.type))) { @@ -4395,6 +4400,8 @@ function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean { // 8.5 Extension if (node.type === "extension") { + items.push({ label: t("extension.viewDetails"), action: () => emit("open-extension-details", node), icon: Info }); + items.push({ label: "", separator: true }); items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value }); return true; } diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 6c12c8f8a5..fd7a350ec9 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2566,6 +2566,8 @@ export default { extension: { manageTitle: "Manage Extensions", installTitle: "Install Extension", + detailsTitle: "Extension Details", + viewDetails: "View details", name: "Extension name", namePlaceholder: "e.g. pg_stat_statements", install: "Install", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 7030606aaa..a39654b7d4 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -5933,6 +5933,8 @@ export default withEnglishFallback({ extension: { installTitle: "Instalar extensión", manageTitle: "Administrar extensión", + detailsTitle: "Detalles de la extensión", + viewDetails: "Ver detalles", name: "Nombre de la extensión", namePlaceholder: "Por ejemplo: pg_stat_statements", install: "Instalar", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 2a521630d8..bd79b4c4c4 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -5933,6 +5933,8 @@ export default withEnglishFallback({ extension: { installTitle: "Installa estensione", manageTitle: "Gestisci estensioni", + detailsTitle: "Dettagli estensione", + viewDetails: "Visualizza dettagli", name: "Nome estensione", namePlaceholder: "Es: pg_stat_statements", install: "Installa", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 1fd3e8e67d..c17132df41 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -5988,6 +5988,8 @@ export default withEnglishFallback({ extension: { installTitle: "拡張機能のインストール", manageTitle: "拡張機能の管理", + detailsTitle: "拡張機能の詳細", + viewDetails: "詳細を表示", name: "拡張機能名", namePlaceholder: "例: pg_stat_statements", install: "インストール", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 82af6404e0..f4d1a5121b 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2440,6 +2440,8 @@ export default withEnglishFallback({ extension: { manageTitle: "확장 관리", installTitle: "확장 설치", + detailsTitle: "확장 세부 정보", + viewDetails: "세부 정보 보기", name: "확장 이름", namePlaceholder: "예: pg_stat_statements", install: "설치", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index bba33ad036..2ff8292d58 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -5935,6 +5935,8 @@ export default withEnglishFallback({ extension: { installTitle: "Instalar extensão", manageTitle: "Gerenciar extensão", + detailsTitle: "Detalhes da extensão", + viewDetails: "Ver detalhes", name: "Nome da extensão", namePlaceholder: "Exemplo: pg_stat_statements", install: "Instalar", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index a7d32e0df0..16c6cff075 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2566,6 +2566,8 @@ export default withEnglishFallback({ extension: { installTitle: "安装扩展", manageTitle: "管理扩展", + detailsTitle: "扩展详情", + viewDetails: "查看详情", name: "扩展名称", namePlaceholder: "例如: pg_stat_statements", install: "安装", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 4cff12f767..09dbc7ce8d 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -5928,6 +5928,8 @@ export default withEnglishFallback({ extension: { installTitle: "安裝擴展", manageTitle: "管理擴展", + detailsTitle: "擴展詳情", + viewDetails: "查看詳情", name: "擴展名稱", namePlaceholder: "例如:pg_stat_statements", install: "安裝", diff --git a/apps/desktop/src/lib/sidebar/treeNodeClick.ts b/apps/desktop/src/lib/sidebar/treeNodeClick.ts index c568f1c156..9d77226667 100644 --- a/apps/desktop/src/lib/sidebar/treeNodeClick.ts +++ b/apps/desktop/src/lib/sidebar/treeNodeClick.ts @@ -1,8 +1,8 @@ import type { ObjectSourceKind, TreeNode, TreeNodeType } from "@/types/database"; import { matchesShortcut, type ShortcutLikeEvent } from "@/lib/editor/keyboardShortcuts"; -export type TreeNodeRowAction = "open-data" | "open-source" | "toggle" | "none"; -export type TreeNodeRowDoubleClickAction = "open-data" | "activate-data" | "open-object-browser" | "open-object-browser-and-expand" | "open-source" | "open-saved-sql" | "toggle" | "none"; +export type TreeNodeRowAction = "open-data" | "open-source" | "open-extension-details" | "toggle" | "none"; +export type TreeNodeRowDoubleClickAction = "open-data" | "activate-data" | "open-object-browser" | "open-object-browser-and-expand" | "open-source" | "open-extension-details" | "open-saved-sql" | "toggle" | "none"; export type SidebarSelectionCopyAction = "copy-name" | "none"; export type SidebarActivation = "single" | "double"; @@ -36,6 +36,7 @@ export function isDocumentBrowserTreeNode(type: TreeNodeType): boolean { export function treeNodeRowAction(type: TreeNodeType, canExpand: boolean, activation: SidebarActivation = "single"): TreeNodeRowAction { if (activation === "double") return "none"; + if (type === "extension") return "open-extension-details"; if (dataNodeTypes.has(type)) return "open-data"; if (sourceNodeTypes.has(type)) return "open-source"; if (toggleLeafNodeTypes.has(type)) return "toggle"; @@ -55,6 +56,7 @@ export function treeNodeRowDoubleClickAction(type: TreeNodeType, canOpenObjectBr // sequence. Only double-click activation needs a second-stage table action. if (type === "table") return activation === "double" ? "activate-data" : "none"; if (activation === "double") { + if (type === "extension") return "open-extension-details"; if (dataNodeTypes.has(type)) return "open-data"; if (sourceNodeTypes.has(type)) return "open-source"; if (savedSqlNodeTypes.has(type)) return "open-saved-sql"; diff --git a/packages/app-tests/treeNodeClick.test.ts b/packages/app-tests/treeNodeClick.test.ts index b607c136f3..5753c2dff4 100644 --- a/packages/app-tests/treeNodeClick.test.ts +++ b/packages/app-tests/treeNodeClick.test.ts @@ -16,6 +16,12 @@ test("single click navigation mode opens source-capable rows", () => { assert.equal(treeNodeRowAction("sequence", false), "open-source"); }); +test("extension rows open their metadata details", () => { + assert.equal(treeNodeRowAction("extension", false, "single"), "open-extension-details"); + assert.equal(treeNodeRowAction("extension", false, "double"), "none"); + assert.equal(treeNodeRowDoubleClickAction("extension", false, "double"), "open-extension-details"); +}); + test("double click navigation mode selects rows on single click", () => { assert.equal(treeNodeRowAction("table", true, "double"), "none"); assert.equal(treeNodeRowAction("view", true, "double"), "none");