From 3da67542d1bfdbad0d008b9107e8a8439d41c247 Mon Sep 17 00:00:00 2001 From: misha-db Date: Tue, 24 Mar 2026 16:26:45 +0400 Subject: [PATCH 01/17] Unity Catalog --- packages/databricks-vscode/package.json | 17 ++ packages/databricks-vscode/src/extension.ts | 17 ++ .../UnityCatalogTreeDataProvider.test.ts | 197 +++++++++++++ .../UnityCatalogTreeDataProvider.ts | 276 ++++++++++++++++++ 4 files changed, 507 insertions(+) create mode 100644 packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.test.ts create mode 100644 packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts diff --git a/packages/databricks-vscode/package.json b/packages/databricks-vscode/package.json index 8bc17b8a2..78fa2f6db 100644 --- a/packages/databricks-vscode/package.json +++ b/packages/databricks-vscode/package.json @@ -187,6 +187,13 @@ "enablement": "databricks.context.activated && databricks.context.loggedIn && databricks.feature.views.workspace", "category": "Databricks" }, + { + "command": "databricks.unityCatalog.refresh", + "title": "Refresh Unity Catalog view", + "icon": "$(refresh)", + "enablement": "databricks.context.activated && databricks.context.loggedIn", + "category": "Databricks" + }, { "command": "databricks.call", "title": "Call", @@ -448,6 +455,11 @@ "name": "Workspace explorer", "when": "databricks.feature.views.workspace" }, + { + "id": "unityCatalogView", + "name": "Unity Catalog", + "when": "databricks.context.activated && databricks.context.loggedIn" + }, { "id": "databricksDocsView", "name": "Documentation" @@ -531,6 +543,11 @@ "when": "view == workspaceFsView", "group": "navigation@1" }, + { + "command": "databricks.unityCatalog.refresh", + "when": "view == unityCatalogView", + "group": "navigation@1" + }, { "command": "databricks.bundle.refreshRemoteState", "when": "view == dabsResourceExplorerView && databricks.context.bundle.deploymentState == idle", diff --git a/packages/databricks-vscode/src/extension.ts b/packages/databricks-vscode/src/extension.ts index 4713b02eb..5e8ef2f30 100644 --- a/packages/databricks-vscode/src/extension.ts +++ b/packages/databricks-vscode/src/extension.ts @@ -74,6 +74,7 @@ import {SyncCommands} from "./sync/SyncCommands"; import {CodeSynchronizer} from "./sync"; import {BundlePipelinesManager} from "./bundle/BundlePipelinesManager"; import {DocsViewTreeDataProvider} from "./ui/docs-view/DocsViewTreeDataProvider"; +import {UnityCatalogTreeDataProvider} from "./ui/unity-catalog/UnityCatalogTreeDataProvider"; // eslint-disable-next-line @typescript-eslint/no-var-requires const packageJson = require("../package.json"); @@ -370,6 +371,22 @@ export async function activate( ) ); + const unityCatalogTreeDataProvider = new UnityCatalogTreeDataProvider( + connectionManager + ); + context.subscriptions.push( + unityCatalogTreeDataProvider, + window.registerTreeDataProvider( + "unityCatalogView", + unityCatalogTreeDataProvider + ), + telemetry.registerCommand( + "databricks.unityCatalog.refresh", + unityCatalogTreeDataProvider.refresh, + unityCatalogTreeDataProvider + ) + ); + const configureAutocomplete = new ConfigureAutocomplete( context, stateStorage, diff --git a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.test.ts b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.test.ts new file mode 100644 index 000000000..69773d7c8 --- /dev/null +++ b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.test.ts @@ -0,0 +1,197 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +import assert from "assert"; +import {anything, instance, mock, when} from "ts-mockito"; +import {Disposable} from "vscode"; +import {WorkspaceClient} from "@databricks/sdk-experimental"; +import { + CatalogsService, + SchemasService, + TablesService, + VolumesService, +} from "@databricks/sdk-experimental/dist/apis/catalog/api"; +import { + ConnectionManager, + ConnectionState, +} from "../../configuration/ConnectionManager"; +import {resolveProviderResult} from "../../test/utils"; +import { + UnityCatalogTreeDataProvider, + UnityCatalogTreeNode, +} from "./UnityCatalogTreeDataProvider"; + +describe(__filename, () => { + let disposables: Disposable[] = []; + let mockConnectionManager: ConnectionManager; + let mockWorkspaceClient: WorkspaceClient; + let mockCatalogs: CatalogsService; + let mockSchemas: SchemasService; + let mockTables: TablesService; + let mockVolumes: VolumesService; + let onDidChangeStateHandler: (s: ConnectionState) => void; + + beforeEach(() => { + disposables = []; + onDidChangeStateHandler = () => {}; + + mockCatalogs = mock(CatalogsService); + when(mockCatalogs.list(anything(), anything())).thenCall(() => { + async function* impl() { + yield {name: "c_b", full_name: "c_b"}; + yield {name: "c_a", full_name: "c_a"}; + } + return impl(); + }); + + mockSchemas = mock(SchemasService); + when(mockSchemas.list(anything(), anything())).thenCall(() => { + async function* impl() { + yield {name: "s_b", full_name: "cat.s_b"}; + yield {name: "s_a", full_name: "cat.s_a"}; + } + return impl(); + }); + + mockTables = mock(TablesService); + when(mockTables.list(anything(), anything())).thenCall(() => { + async function* impl() { + yield { + name: "t1", + full_name: "cat.sch.t1", + table_type: "MANAGED", + }; + } + return impl(); + }); + + mockVolumes = mock(VolumesService); + when(mockVolumes.list(anything(), anything())).thenCall(() => { + async function* impl() { + yield {name: "v1", full_name: "cat.sch.v1"}; + } + return impl(); + }); + + mockWorkspaceClient = mock(WorkspaceClient); + when(mockWorkspaceClient.catalogs).thenReturn(instance(mockCatalogs)); + when(mockWorkspaceClient.schemas).thenReturn(instance(mockSchemas)); + when(mockWorkspaceClient.tables).thenReturn(instance(mockTables)); + when(mockWorkspaceClient.volumes).thenReturn(instance(mockVolumes)); + + mockConnectionManager = mock(ConnectionManager); + when(mockConnectionManager.workspaceClient).thenReturn( + instance(mockWorkspaceClient) + ); + when(mockConnectionManager.onDidChangeState).thenReturn( + (cb: (s: ConnectionState) => void) => { + onDidChangeStateHandler = cb; + return {dispose() {}}; + } + ); + }); + + afterEach(() => { + disposables.forEach((d) => d.dispose()); + }); + + it("returns undefined when not connected", async () => { + when(mockConnectionManager.workspaceClient).thenReturn(undefined); + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager) + ); + disposables.push(provider); + + const children = await resolveProviderResult(provider.getChildren()); + assert.strictEqual(children, undefined); + }); + + it("lists catalogs sorted by name", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager) + ); + disposables.push(provider); + + const children = (await resolveProviderResult( + provider.getChildren() + )) as UnityCatalogTreeNode[]; + assert(children); + assert.strictEqual(children.length, 2); + const first = children[0]; + const second = children[1]; + assert.strictEqual(first.kind, "catalog"); + assert.strictEqual(second.kind, "catalog"); + if (first.kind !== "catalog" || second.kind !== "catalog") { + assert.fail("expected catalogs"); + } + assert.strictEqual(first.name, "c_a"); + assert.strictEqual(second.name, "c_b"); + }); + + it("lists schemas under a catalog", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager) + ); + disposables.push(provider); + + const catalog: UnityCatalogTreeNode = { + kind: "catalog", + name: "cat", + fullName: "cat", + }; + const children = (await resolveProviderResult( + provider.getChildren(catalog) + )) as UnityCatalogTreeNode[]; + + assert(children); + assert.strictEqual(children.length, 2); + assert.strictEqual(children[0].kind, "schema"); + assert.strictEqual(children[0].name, "s_a"); + assert.strictEqual((children[0] as {catalogName: string}).catalogName, "cat"); + }); + + it("lists tables and volumes under a schema", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager) + ); + disposables.push(provider); + + const schema: UnityCatalogTreeNode = { + kind: "schema", + catalogName: "cat", + name: "sch", + fullName: "cat.sch", + }; + const children = (await resolveProviderResult( + provider.getChildren(schema) + )) as UnityCatalogTreeNode[]; + + assert(children); + assert.strictEqual(children.length, 2); + const kinds = children.map((c) => c.kind).sort(); + assert.deepStrictEqual(kinds, ["table", "volume"]); + const table = children.find((c) => c.kind === "table"); + assert(table && table.kind === "table"); + assert.strictEqual(table.name, "t1"); + const volume = children.find((c) => c.kind === "volume"); + assert(volume && volume.kind === "volume"); + assert.strictEqual(volume.name, "v1"); + }); + + it("fires onDidChangeTreeData when connection state changes", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager) + ); + disposables.push(provider); + + let count = 0; + disposables.push( + provider.onDidChangeTreeData(() => { + count += 1; + }) + ); + + assert.strictEqual(count, 0); + onDidChangeStateHandler("CONNECTED"); + assert.strictEqual(count, 1); + }); +}); diff --git a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts new file mode 100644 index 000000000..990b8199c --- /dev/null +++ b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts @@ -0,0 +1,276 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import {ApiError, logging} from "@databricks/sdk-experimental"; +import { + Disposable, + EventEmitter, + ThemeColor, + ThemeIcon, + TreeDataProvider, + TreeItem, + TreeItemCollapsibleState, +} from "vscode"; +import {ConnectionManager} from "../../configuration/ConnectionManager"; +import {Loggers} from "../../logger"; + +const logger = logging.NamedLogger.getOrCreate(Loggers.Extension); + +export type UnityCatalogTreeNode = + | {kind: "catalog"; name: string; fullName: string} + | { + kind: "schema"; + catalogName: string; + name: string; + fullName: string; + } + | { + kind: "table"; + catalogName: string; + schemaName: string; + name: string; + fullName: string; + tableType?: string; + } + | { + kind: "volume"; + catalogName: string; + schemaName: string; + name: string; + fullName: string; + } + | {kind: "error"; message: string}; + +async function drainAsyncIterable(iter: AsyncIterable): Promise { + const out: T[] = []; + for await (const item of iter) { + out.push(item); + } + return out; +} + +export class UnityCatalogTreeDataProvider + implements TreeDataProvider, Disposable +{ + private readonly _onDidChangeTreeData = new EventEmitter< + UnityCatalogTreeNode | undefined | void + >(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + private readonly disposables: Disposable[] = []; + + constructor(private readonly connectionManager: ConnectionManager) { + this.disposables.push( + this.connectionManager.onDidChangeState(() => { + this._onDidChangeTreeData.fire(undefined); + }) + ); + } + + getTreeItem(element: UnityCatalogTreeNode): TreeItem { + if (element.kind === "error") { + return { + label: element.message, + iconPath: new ThemeIcon( + "error", + new ThemeColor("notificationsErrorIcon.foreground") + ), + collapsibleState: TreeItemCollapsibleState.None, + }; + } + + if (element.kind === "catalog") { + return { + label: element.name, + tooltip: element.fullName, + iconPath: new ThemeIcon( + "library", + new ThemeColor("charts.purple") + ), + contextValue: "unityCatalog.catalog", + collapsibleState: TreeItemCollapsibleState.Collapsed, + }; + } + + if (element.kind === "schema") { + return { + label: element.name, + tooltip: element.fullName, + iconPath: new ThemeIcon( + "folder-library", + new ThemeColor("charts.green") + ), + contextValue: "unityCatalog.schema", + collapsibleState: TreeItemCollapsibleState.Collapsed, + }; + } + + if (element.kind === "volume") { + return { + label: element.name, + tooltip: element.fullName, + iconPath: new ThemeIcon( + "package", + new ThemeColor("charts.blue") + ), + contextValue: "unityCatalog.volume", + collapsibleState: TreeItemCollapsibleState.None, + }; + } + + const typeSuffix = + element.tableType && element.tableType !== "MANAGED" + ? ` (${element.tableType})` + : ""; + return { + label: `${element.name}${typeSuffix}`, + tooltip: [element.fullName, element.tableType] + .filter(Boolean) + .join(" · "), + iconPath: new ThemeIcon("table", new ThemeColor("charts.orange")), + contextValue: "unityCatalog.table", + collapsibleState: TreeItemCollapsibleState.None, + }; + } + + getChildren( + element?: UnityCatalogTreeNode + ): Thenable { + const client = this.connectionManager.workspaceClient; + if (!client) { + return Promise.resolve(undefined); + } + + if (!element) { + return this.listCatalogs(client); + } + + if (element.kind === "error") { + return Promise.resolve(undefined); + } + + if (element.kind === "catalog") { + return this.listSchemas(client, element.name); + } + + if (element.kind === "schema") { + return this.listTablesAndVolumes( + client, + element.catalogName, + element.name + ); + } + + return Promise.resolve(undefined); + } + + private async listCatalogs( + client: NonNullable + ): Promise { + try { + const rows = await drainAsyncIterable(client.catalogs.list({})); + return rows + .filter((c) => c.name) + .map((c) => ({ + kind: "catalog" as const, + name: c.name!, + fullName: c.full_name ?? c.name!, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + } catch (e) { + return this.errorChildren(e, "catalogs"); + } + } + + private async listSchemas( + client: NonNullable, + catalogName: string + ): Promise { + try { + const rows = await drainAsyncIterable( + client.schemas.list({catalog_name: catalogName}) + ); + return rows + .filter((s) => s.name) + .map((s) => ({ + kind: "schema" as const, + catalogName, + name: s.name!, + fullName: s.full_name ?? `${catalogName}.${s.name}`, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + } catch (e) { + return this.errorChildren(e, "schemas"); + } + } + + private async listTablesAndVolumes( + client: NonNullable, + catalogName: string, + schemaName: string + ): Promise { + try { + const [tableRows, volumeRows] = await Promise.all([ + drainAsyncIterable( + client.tables.list({catalog_name: catalogName, schema_name: schemaName}) + ), + drainAsyncIterable( + client.volumes.list({catalog_name: catalogName, schema_name: schemaName}) + ), + ]); + + const tableNodes: UnityCatalogTreeNode[] = tableRows + .filter((t) => t.name) + .map((t) => ({ + kind: "table" as const, + catalogName, + schemaName, + name: t.name!, + fullName: t.full_name ?? `${catalogName}.${schemaName}.${t.name}`, + tableType: t.table_type, + })); + + const volumeNodes: UnityCatalogTreeNode[] = volumeRows + .filter((v) => v.name) + .map((v) => ({ + kind: "volume" as const, + catalogName, + schemaName, + name: v.name!, + fullName: v.full_name ?? `${catalogName}.${schemaName}.${v.name}`, + })); + + return [...tableNodes, ...volumeNodes].sort((a, b) => { + const an = a.kind === "table" || a.kind === "volume" ? a.name : ""; + const bn = b.kind === "table" || b.kind === "volume" ? b.name : ""; + const c = an.localeCompare(bn); + if (c !== 0) { + return c; + } + if (a.kind === b.kind) { + return 0; + } + return a.kind === "table" ? -1 : 1; + }); + } catch (e) { + return this.errorChildren(e, "tables and volumes"); + } + } + + private errorChildren( + e: unknown, + resource: string + ): UnityCatalogTreeNode[] | undefined { + const message = + e instanceof ApiError + ? `Failed to load ${resource}: ${e.message}` + : `Failed to load ${resource}`; + logger.error(`Unity Catalog: ${message}`, e); + return [{kind: "error", message}]; + } + + refresh(): void { + this._onDidChangeTreeData.fire(undefined); + } + + dispose(): void { + this.disposables.forEach((d) => d.dispose()); + } +} From f6d7ce239a41906aec71b5104421478b1015d916 Mon Sep 17 00:00:00 2001 From: misha-db Date: Mon, 30 Mar 2026 20:36:49 +0400 Subject: [PATCH 02/17] [WIP] Unity catalog explorer --- packages/databricks-vscode/package.json | 48 ++- packages/databricks-vscode/src/extension.ts | 40 +- .../UnityCatalogTreeDataProvider.test.ts | 355 +++++++++++++++++- .../UnityCatalogTreeDataProvider.ts | 350 +++++++++++++++-- 4 files changed, 749 insertions(+), 44 deletions(-) diff --git a/packages/databricks-vscode/package.json b/packages/databricks-vscode/package.json index 78fa2f6db..78f1aa7d5 100644 --- a/packages/databricks-vscode/package.json +++ b/packages/databricks-vscode/package.json @@ -194,6 +194,22 @@ "enablement": "databricks.context.activated && databricks.context.loggedIn", "category": "Databricks" }, + { + "command": "databricks.unityCatalog.copyStorageLocation", + "title": "Copy storage location", + "category": "Databricks" + }, + { + "command": "databricks.unityCatalog.copyViewSql", + "title": "Copy view SQL", + "category": "Databricks" + }, + { + "command": "databricks.unityCatalog.refreshNode", + "title": "Refresh", + "icon": "$(refresh)", + "category": "Databricks" + }, { "command": "databricks.call", "title": "Call", @@ -604,6 +620,36 @@ "when": "viewItem =~ /^databricks.*\\.(has-url).*$/ && databricks.context.bundle.deploymentState == idle", "group": "navigation_2@0" }, + { + "command": "databricks.utils.openExternal", + "when": "view == unityCatalogView && viewItem =~ /\\.has-url$/", + "group": "inline@1" + }, + { + "command": "databricks.utils.openExternal", + "when": "view == unityCatalogView && viewItem =~ /\\.has-url$/", + "group": "navigation_2@0" + }, + { + "command": "databricks.unityCatalog.copyStorageLocation", + "when": "view == unityCatalogView && viewItem =~ /\\.has-storage/", + "group": "navigation_2@1" + }, + { + "command": "databricks.unityCatalog.copyViewSql", + "when": "view == unityCatalogView && viewItem =~ /\\.is-view/", + "group": "navigation_2@2" + }, + { + "command": "databricks.unityCatalog.refreshNode", + "when": "view == unityCatalogView && viewItem =~ /^unityCatalog\\.(?!column)/", + "group": "navigation_2@3" + }, + { + "command": "databricks.unityCatalog.refreshNode", + "when": "view == unityCatalogView && viewItem =~ /^unityCatalog\\.(?!column)/", + "group": "inline@2" + }, { "command": "databricks.utils.goToDefinition", "when": "viewItem =~ /^databricks.*\\.(has-source-location).*$/", @@ -741,7 +787,7 @@ }, { "command": "databricks.utils.copy", - "when": "view == dabsResourceExplorerView || view == configurationView", + "when": "view == dabsResourceExplorerView || view == configurationView || view == unityCatalogView", "group": "navigation_2@0" }, { diff --git a/packages/databricks-vscode/src/extension.ts b/packages/databricks-vscode/src/extension.ts index 5e8ef2f30..047281091 100644 --- a/packages/databricks-vscode/src/extension.ts +++ b/packages/databricks-vscode/src/extension.ts @@ -1,6 +1,7 @@ import { commands, debug, + env, ExtensionContext, extensions, window, @@ -74,7 +75,10 @@ import {SyncCommands} from "./sync/SyncCommands"; import {CodeSynchronizer} from "./sync"; import {BundlePipelinesManager} from "./bundle/BundlePipelinesManager"; import {DocsViewTreeDataProvider} from "./ui/docs-view/DocsViewTreeDataProvider"; -import {UnityCatalogTreeDataProvider} from "./ui/unity-catalog/UnityCatalogTreeDataProvider"; +import { + UnityCatalogTreeDataProvider, + UnityCatalogTreeNode, +} from "./ui/unity-catalog/UnityCatalogTreeDataProvider"; // eslint-disable-next-line @typescript-eslint/no-var-requires const packageJson = require("../package.json"); @@ -374,16 +378,42 @@ export async function activate( const unityCatalogTreeDataProvider = new UnityCatalogTreeDataProvider( connectionManager ); + const unityCatalogTreeView = window.createTreeView("unityCatalogView", { + treeDataProvider: unityCatalogTreeDataProvider, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + filterOnType: true, + } as any); context.subscriptions.push( unityCatalogTreeDataProvider, - window.registerTreeDataProvider( - "unityCatalogView", - unityCatalogTreeDataProvider - ), + unityCatalogTreeView, telemetry.registerCommand( "databricks.unityCatalog.refresh", unityCatalogTreeDataProvider.refresh, unityCatalogTreeDataProvider + ), + telemetry.registerCommand( + "databricks.unityCatalog.refreshNode", + (node: UnityCatalogTreeNode) => + unityCatalogTreeDataProvider.refreshNode(node) + ), + telemetry.registerCommand( + "databricks.unityCatalog.copyStorageLocation", + async (node: UnityCatalogTreeNode) => { + if ( + (node.kind === "table" || node.kind === "volume") && + node.storageLocation + ) { + await env.clipboard.writeText(node.storageLocation); + } + } + ), + telemetry.registerCommand( + "databricks.unityCatalog.copyViewSql", + async (node: UnityCatalogTreeNode) => { + if (node.kind === "table" && node.viewDefinition) { + await env.clipboard.writeText(node.viewDefinition); + } + } ) ); diff --git a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.test.ts b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.test.ts index 69773d7c8..84976413e 100644 --- a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.test.ts +++ b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.test.ts @@ -6,6 +6,7 @@ import {Disposable} from "vscode"; import {WorkspaceClient} from "@databricks/sdk-experimental"; import { CatalogsService, + FunctionsService, SchemasService, TablesService, VolumesService, @@ -17,6 +18,7 @@ import { import {resolveProviderResult} from "../../test/utils"; import { UnityCatalogTreeDataProvider, + UnityCatalogTreeItem, UnityCatalogTreeNode, } from "./UnityCatalogTreeDataProvider"; @@ -28,6 +30,7 @@ describe(__filename, () => { let mockSchemas: SchemasService; let mockTables: TablesService; let mockVolumes: VolumesService; + let mockFunctions: FunctionsService; let onDidChangeStateHandler: (s: ConnectionState) => void; beforeEach(() => { @@ -59,6 +62,23 @@ describe(__filename, () => { name: "t1", full_name: "cat.sch.t1", table_type: "MANAGED", + data_source_format: "DELTA", + comment: "a test table", + owner: "alice", + columns: [ + { + name: "id", + type_text: "bigint", + nullable: false, + position: 0, + }, + { + name: "name", + type_text: "string", + nullable: true, + position: 1, + }, + ], }; } return impl(); @@ -67,7 +87,23 @@ describe(__filename, () => { mockVolumes = mock(VolumesService); when(mockVolumes.list(anything(), anything())).thenCall(() => { async function* impl() { - yield {name: "v1", full_name: "cat.sch.v1"}; + yield { + name: "v1", + full_name: "cat.sch.v1", + volume_type: "MANAGED", + }; + } + return impl(); + }); + + mockFunctions = mock(FunctionsService); + when(mockFunctions.list(anything(), anything())).thenCall(() => { + async function* impl() { + yield { + name: "f1", + catalog_name: "cat", + schema_name: "sch", + }; } return impl(); }); @@ -77,6 +113,7 @@ describe(__filename, () => { when(mockWorkspaceClient.schemas).thenReturn(instance(mockSchemas)); when(mockWorkspaceClient.tables).thenReturn(instance(mockTables)); when(mockWorkspaceClient.volumes).thenReturn(instance(mockVolumes)); + when(mockWorkspaceClient.functions).thenReturn(instance(mockFunctions)); mockConnectionManager = mock(ConnectionManager); when(mockConnectionManager.workspaceClient).thenReturn( @@ -146,10 +183,13 @@ describe(__filename, () => { assert.strictEqual(children.length, 2); assert.strictEqual(children[0].kind, "schema"); assert.strictEqual(children[0].name, "s_a"); - assert.strictEqual((children[0] as {catalogName: string}).catalogName, "cat"); + assert.strictEqual( + (children[0] as {catalogName: string}).catalogName, + "cat" + ); }); - it("lists tables and volumes under a schema", async () => { + it("lists tables, volumes, and functions under a schema", async () => { const provider = new UnityCatalogTreeDataProvider( instance(mockConnectionManager) ); @@ -166,15 +206,22 @@ describe(__filename, () => { )) as UnityCatalogTreeNode[]; assert(children); - assert.strictEqual(children.length, 2); + assert.strictEqual(children.length, 3); const kinds = children.map((c) => c.kind).sort(); - assert.deepStrictEqual(kinds, ["table", "volume"]); + assert.deepStrictEqual(kinds, ["function", "table", "volume"]); + const table = children.find((c) => c.kind === "table"); assert(table && table.kind === "table"); assert.strictEqual(table.name, "t1"); + const volume = children.find((c) => c.kind === "volume"); assert(volume && volume.kind === "volume"); assert.strictEqual(volume.name, "v1"); + + const fn = children.find((c) => c.kind === "function"); + assert(fn && fn.kind === "function"); + assert.strictEqual(fn.name, "f1"); + assert.strictEqual(fn.fullName, "cat.sch.f1"); }); it("fires onDidChangeTreeData when connection state changes", async () => { @@ -194,4 +241,302 @@ describe(__filename, () => { onDidChangeStateHandler("CONNECTED"); assert.strictEqual(count, 1); }); + + it("getTreeItem sets url when host is available", async () => { + const stubManager = { + onDidChangeState: () => ({dispose() {}}), + databricksWorkspace: { + host: new URL("https://adb-123.azuredatabricks.net/"), + }, + } as unknown as ConnectionManager; + + const provider = new UnityCatalogTreeDataProvider(stubManager); + disposables.push(provider); + + const catalog: UnityCatalogTreeNode = { + kind: "catalog", + name: "cat", + fullName: "cat", + }; + const item = provider.getTreeItem(catalog) as UnityCatalogTreeItem; + + assert(item.url, "url should be set"); + assert( + item.url!.includes("explore/data/cat"), + `url should contain explore/data/cat, got: ${item.url}` + ); + assert( + item.contextValue?.endsWith(".has-url"), + `contextValue should end with .has-url, got: ${item.contextValue}` + ); + assert.strictEqual(item.copyText, "cat"); + }); + + it("getTreeItem omits url when no host", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager) + ); + disposables.push(provider); + + const catalog: UnityCatalogTreeNode = { + kind: "catalog", + name: "cat", + fullName: "cat", + }; + const item = provider.getTreeItem(catalog) as UnityCatalogTreeItem; + + assert.strictEqual(item.url, undefined); + assert.strictEqual(item.contextValue, "unityCatalog.catalog"); + }); + + it("getTreeItem for function node", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager) + ); + disposables.push(provider); + + const fn: UnityCatalogTreeNode = { + kind: "function", + catalogName: "cat", + schemaName: "sch", + name: "f1", + fullName: "cat.sch.f1", + }; + const item = provider.getTreeItem(fn) as UnityCatalogTreeItem; + + assert.strictEqual(item.label, "f1"); + assert.strictEqual(item.copyText, "cat.sch.f1"); + assert( + item.contextValue === "unityCatalog.function" || + item.contextValue === "unityCatalog.function.has-url" + ); + }); + + it("table node carries enriched fields", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager) + ); + disposables.push(provider); + + const schema: UnityCatalogTreeNode = { + kind: "schema", + catalogName: "cat", + name: "sch", + fullName: "cat.sch", + }; + const children = (await resolveProviderResult( + provider.getChildren(schema) + )) as UnityCatalogTreeNode[]; + + const table = children.find((c) => c.kind === "table"); + assert(table && table.kind === "table"); + assert.strictEqual(table.dataSourceFormat, "DELTA"); + assert.strictEqual(table.comment, "a test table"); + assert.strictEqual(table.owner, "alice"); + assert(table.columns && table.columns.length === 2); + assert.strictEqual(table.columns[0].name, "id"); + assert.strictEqual(table.columns[0].typeText, "bigint"); + assert.strictEqual(table.columns[0].nullable, false); + }); + + it("getChildren for table with columns returns sorted column nodes", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager) + ); + disposables.push(provider); + + const tableNode: UnityCatalogTreeNode = { + kind: "table", + catalogName: "cat", + schemaName: "sch", + name: "t1", + fullName: "cat.sch.t1", + columns: [ + {name: "b_col", typeText: "string", position: 1}, + {name: "a_col", typeText: "bigint", position: 0}, + ], + }; + const children = (await resolveProviderResult( + provider.getChildren(tableNode) + )) as UnityCatalogTreeNode[]; + + assert(children); + assert.strictEqual(children.length, 2); + assert.strictEqual(children[0].kind, "column"); + if (children[0].kind === "column") { + assert.strictEqual(children[0].name, "a_col"); + } + assert.strictEqual(children[1].kind, "column"); + if (children[1].kind === "column") { + assert.strictEqual(children[1].name, "b_col"); + } + }); + + it("getChildren for table without columns returns undefined", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager) + ); + disposables.push(provider); + + const tableNode: UnityCatalogTreeNode = { + kind: "table", + catalogName: "cat", + schemaName: "sch", + name: "t1", + fullName: "cat.sch.t1", + columns: [], + }; + const children = await resolveProviderResult( + provider.getChildren(tableNode) + ); + assert.strictEqual(children, undefined); + }); + + it("getTreeItem for non-nullable column uses symbol-key icon", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager) + ); + disposables.push(provider); + + const col: UnityCatalogTreeNode = { + kind: "column", + tableFullName: "cat.sch.t1", + name: "id", + typeText: "bigint", + nullable: false, + }; + const item = provider.getTreeItem(col) as UnityCatalogTreeItem; + assert.strictEqual(item.label, "id"); + assert.strictEqual(item.description, "bigint"); + const icon = item.iconPath as {id: string}; + assert.strictEqual(icon.id, "symbol-key"); + }); + + it("getTreeItem for nullable column uses symbol-field icon", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager) + ); + disposables.push(provider); + + const col: UnityCatalogTreeNode = { + kind: "column", + tableFullName: "cat.sch.t1", + name: "val", + typeText: "string", + nullable: true, + }; + const item = provider.getTreeItem(col) as UnityCatalogTreeItem; + const icon = item.iconPath as {id: string}; + assert.strictEqual(icon.id, "symbol-field"); + }); + + it("getTreeItem for EXTERNAL table with storage has has-storage in contextValue", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager) + ); + disposables.push(provider); + + const tableNode: UnityCatalogTreeNode = { + kind: "table", + catalogName: "cat", + schemaName: "sch", + name: "ext", + fullName: "cat.sch.ext", + tableType: "EXTERNAL", + storageLocation: "s3://bucket/path", + }; + const item = provider.getTreeItem(tableNode) as UnityCatalogTreeItem; + assert( + item.contextValue?.includes("has-storage"), + `expected has-storage in contextValue, got: ${item.contextValue}` + ); + assert.strictEqual(item.storageLocation, "s3://bucket/path"); + }); + + it("getTreeItem for VIEW table with view_definition has is-view in contextValue", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager) + ); + disposables.push(provider); + + const tableNode: UnityCatalogTreeNode = { + kind: "table", + catalogName: "cat", + schemaName: "sch", + name: "vw", + fullName: "cat.sch.vw", + tableType: "VIEW", + viewDefinition: "SELECT 1", + }; + const item = provider.getTreeItem(tableNode) as UnityCatalogTreeItem; + assert( + item.contextValue?.includes("is-view"), + `expected is-view in contextValue, got: ${item.contextValue}` + ); + assert.strictEqual(item.viewDefinition, "SELECT 1"); + }); + + it("volume node carries volumeType and shows EXTERNAL label suffix", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager) + ); + disposables.push(provider); + + const volNode: UnityCatalogTreeNode = { + kind: "volume", + catalogName: "cat", + schemaName: "sch", + name: "ev", + fullName: "cat.sch.ev", + volumeType: "EXTERNAL", + storageLocation: "s3://bucket/vol", + }; + const item = provider.getTreeItem(volNode) as UnityCatalogTreeItem; + assert.strictEqual(item.label, "ev (EXTERNAL)"); + assert( + item.contextValue?.includes("has-storage"), + `expected has-storage in contextValue, got: ${item.contextValue}` + ); + }); + + it("catalog node carries comment", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager) + ); + disposables.push(provider); + + const catNode: UnityCatalogTreeNode = { + kind: "catalog", + name: "cat", + fullName: "cat", + comment: "my catalog", + }; + const item = provider.getTreeItem(catNode) as UnityCatalogTreeItem; + assert.strictEqual(item.label, "cat"); + }); + + it("returns error when functions API throws", async () => { + when(mockFunctions.list(anything(), anything())).thenThrow( + new Error("functions API unavailable") + ); + + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager) + ); + disposables.push(provider); + + const schema: UnityCatalogTreeNode = { + kind: "schema", + catalogName: "cat", + name: "sch", + fullName: "cat.sch", + }; + const children = (await resolveProviderResult( + provider.getChildren(schema) + )) as UnityCatalogTreeNode[]; + + assert(children); + assert.strictEqual(children.length, 1); + assert.strictEqual(children[0].kind, "error"); + }); }); diff --git a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts index 990b8199c..a7e48f51f 100644 --- a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts +++ b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts @@ -3,6 +3,7 @@ import {ApiError, logging} from "@databricks/sdk-experimental"; import { Disposable, EventEmitter, + MarkdownString, ThemeColor, ThemeIcon, TreeDataProvider, @@ -14,13 +15,23 @@ import {Loggers} from "../../logger"; const logger = logging.NamedLogger.getOrCreate(Loggers.Extension); +export interface ColumnData { + name: string; + typeName?: string; + typeText?: string; + comment?: string; + nullable?: boolean; + position?: number; +} + export type UnityCatalogTreeNode = - | {kind: "catalog"; name: string; fullName: string} + | {kind: "catalog"; name: string; fullName: string; comment?: string} | { kind: "schema"; catalogName: string; name: string; fullName: string; + comment?: string; } | { kind: "table"; @@ -29,6 +40,15 @@ export type UnityCatalogTreeNode = name: string; fullName: string; tableType?: string; + comment?: string; + dataSourceFormat?: string; + storageLocation?: string; + viewDefinition?: string; + owner?: string; + createdBy?: string; + createdAt?: number; + updatedAt?: number; + columns?: ColumnData[]; } | { kind: "volume"; @@ -36,9 +56,37 @@ export type UnityCatalogTreeNode = schemaName: string; name: string; fullName: string; + volumeType?: string; + storageLocation?: string; + comment?: string; + owner?: string; + } + | { + kind: "function"; + catalogName: string; + schemaName: string; + name: string; + fullName: string; + } + | { + kind: "column"; + tableFullName: string; + name: string; + typeName?: string; + typeText?: string; + comment?: string; + nullable?: boolean; + position?: number; } | {kind: "error"; message: string}; +export interface UnityCatalogTreeItem extends TreeItem { + url?: string; + copyText?: string; + storageLocation?: string; + viewDefinition?: string; +} + async function drainAsyncIterable(iter: AsyncIterable): Promise { const out: T[] = []; for await (const item of iter) { @@ -47,6 +95,15 @@ async function drainAsyncIterable(iter: AsyncIterable): Promise { return out; } +function formatTs(ms: number | undefined): string | undefined { + if (ms === undefined) { + return undefined; + } + return ( + new Date(ms).toISOString().replace("T", " ").substring(0, 19) + " UTC" + ); +} + export class UnityCatalogTreeDataProvider implements TreeDataProvider, Disposable { @@ -64,7 +121,15 @@ export class UnityCatalogTreeDataProvider ); } - getTreeItem(element: UnityCatalogTreeNode): TreeItem { + private getExploreUrl(path: string): string | undefined { + const host = this.connectionManager.databricksWorkspace?.host; + if (!host) { + return undefined; + } + return `${host.toString()}explore/data/${path}`; + } + + getTreeItem(element: UnityCatalogTreeNode): UnityCatalogTreeItem { if (element.kind === "error") { return { label: element.message, @@ -77,56 +142,192 @@ export class UnityCatalogTreeDataProvider } if (element.kind === "catalog") { + const url = this.getExploreUrl(element.fullName); + const tt = new MarkdownString(`**${element.fullName}**`); + if (element.comment) { + tt.appendMarkdown(`\n\n${element.comment}`); + } return { label: element.name, - tooltip: element.fullName, + tooltip: tt, iconPath: new ThemeIcon( "library", new ThemeColor("charts.purple") ), - contextValue: "unityCatalog.catalog", + contextValue: url + ? "unityCatalog.catalog.has-url" + : "unityCatalog.catalog", collapsibleState: TreeItemCollapsibleState.Collapsed, + url, + copyText: element.fullName, }; } if (element.kind === "schema") { + const url = this.getExploreUrl(element.fullName); + const tt = new MarkdownString(`**${element.fullName}**`); + if (element.comment) { + tt.appendMarkdown(`\n\n${element.comment}`); + } return { label: element.name, - tooltip: element.fullName, + tooltip: tt, iconPath: new ThemeIcon( "folder-library", new ThemeColor("charts.green") ), - contextValue: "unityCatalog.schema", + contextValue: url + ? "unityCatalog.schema.has-url" + : "unityCatalog.schema", collapsibleState: TreeItemCollapsibleState.Collapsed, + url, + copyText: element.fullName, }; } if (element.kind === "volume") { + const url = this.getExploreUrl(element.fullName); + const isExternal = + element.volumeType !== undefined && + element.volumeType !== "MANAGED"; + const label = isExternal + ? `${element.name} (${element.volumeType})` + : element.name; + const flags = ["unityCatalog.volume"]; + if (url) { + flags.push("has-url"); + } + if (element.storageLocation) { + flags.push("has-storage"); + } + const tt = new MarkdownString(`**${element.fullName}**`); + if (element.volumeType) { + tt.appendMarkdown(`\n\n*Type:* ${element.volumeType}`); + } + if (element.owner) { + tt.appendMarkdown(`\n\n*Owner:* ${element.owner}`); + } + if (element.comment) { + tt.appendMarkdown(`\n\n${element.comment}`); + } return { - label: element.name, - tooltip: element.fullName, + label, + tooltip: tt, iconPath: new ThemeIcon( "package", new ThemeColor("charts.blue") ), - contextValue: "unityCatalog.volume", + contextValue: flags.join("."), + collapsibleState: TreeItemCollapsibleState.None, + url, + copyText: element.fullName, + storageLocation: element.storageLocation, + }; + } + + if (element.kind === "function") { + const url = this.getExploreUrl(element.fullName); + return { + label: element.name, + tooltip: element.fullName, + iconPath: new ThemeIcon( + "symbol-function", + new ThemeColor("charts.yellow") + ), + contextValue: url + ? "unityCatalog.function.has-url" + : "unityCatalog.function", collapsibleState: TreeItemCollapsibleState.None, + url, + copyText: element.fullName, }; } + if (element.kind === "column") { + const icon = + element.nullable === false + ? new ThemeIcon("symbol-key") + : new ThemeIcon("symbol-field"); + const typeLabel = element.typeText ?? element.typeName ?? ""; + const tt = new MarkdownString( + `**${element.name}** \`${typeLabel}\`` + ); + if (element.nullable === false) { + tt.appendMarkdown(" *(not null)*"); + } + if (element.comment) { + tt.appendMarkdown(`\n\n${element.comment}`); + } + return { + label: element.name, + description: typeLabel, + tooltip: tt, + iconPath: icon, + contextValue: "unityCatalog.column", + collapsibleState: TreeItemCollapsibleState.None, + copyText: element.name, + }; + } + + // table const typeSuffix = element.tableType && element.tableType !== "MANAGED" ? ` (${element.tableType})` : ""; + const url = this.getExploreUrl(element.fullName); + const flags = ["unityCatalog.table"]; + if (url) { + flags.push("has-url"); + } + if (element.storageLocation) { + flags.push("has-storage"); + } + const isView = + element.tableType === "VIEW" || + element.tableType === "MATERIALIZED_VIEW"; + if (isView && element.viewDefinition) { + flags.push("is-view"); + } + + const tt = new MarkdownString(`**${element.fullName}**`); + if (element.tableType) { + tt.appendMarkdown(`\n\n*Type:* ${element.tableType}`); + } + if (element.dataSourceFormat) { + tt.appendMarkdown(` · *Format:* ${element.dataSourceFormat}`); + } + if (element.owner) { + tt.appendMarkdown(`\n\n*Owner:* ${element.owner}`); + } + if (element.createdBy) { + tt.appendMarkdown(` · *Created by:* ${element.createdBy}`); + } + const cAt = formatTs(element.createdAt); + const uAt = formatTs(element.updatedAt); + if (cAt) { + tt.appendMarkdown(`\n\n*Created:* ${cAt}`); + } + if (uAt) { + tt.appendMarkdown(` *Updated:* ${uAt}`); + } + if (element.comment) { + tt.appendMarkdown(`\n\n${element.comment}`); + } + + const hasColumns = (element.columns?.length ?? 0) > 0; return { label: `${element.name}${typeSuffix}`, - tooltip: [element.fullName, element.tableType] - .filter(Boolean) - .join(" · "), + description: element.dataSourceFormat, + tooltip: tt, iconPath: new ThemeIcon("table", new ThemeColor("charts.orange")), - contextValue: "unityCatalog.table", - collapsibleState: TreeItemCollapsibleState.None, + contextValue: flags.join("."), + collapsibleState: hasColumns + ? TreeItemCollapsibleState.Collapsed + : TreeItemCollapsibleState.None, + url, + copyText: element.fullName, + storageLocation: element.storageLocation, + viewDefinition: element.viewDefinition, }; } @@ -151,13 +352,33 @@ export class UnityCatalogTreeDataProvider } if (element.kind === "schema") { - return this.listTablesAndVolumes( + return this.listSchemaChildren( client, element.catalogName, element.name ); } + if (element.kind === "table") { + if (!element.columns?.length) { + return Promise.resolve(undefined); + } + return Promise.resolve( + [...element.columns] + .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)) + .map((col) => ({ + kind: "column" as const, + tableFullName: element.fullName, + name: col.name, + typeName: col.typeName, + typeText: col.typeText, + comment: col.comment, + nullable: col.nullable, + position: col.position, + })) + ); + } + return Promise.resolve(undefined); } @@ -172,6 +393,7 @@ export class UnityCatalogTreeDataProvider kind: "catalog" as const, name: c.name!, fullName: c.full_name ?? c.name!, + comment: c.comment, })) .sort((a, b) => a.name.localeCompare(b.name)); } catch (e) { @@ -194,6 +416,7 @@ export class UnityCatalogTreeDataProvider catalogName, name: s.name!, fullName: s.full_name ?? `${catalogName}.${s.name}`, + comment: s.comment, })) .sort((a, b) => a.name.localeCompare(b.name)); } catch (e) { @@ -201,18 +424,30 @@ export class UnityCatalogTreeDataProvider } } - private async listTablesAndVolumes( + private async listSchemaChildren( client: NonNullable, catalogName: string, schemaName: string ): Promise { try { - const [tableRows, volumeRows] = await Promise.all([ + const [tableRows, volumeRows, functionRows] = await Promise.all([ + drainAsyncIterable( + client.tables.list({ + catalog_name: catalogName, + schema_name: schemaName, + }) + ), drainAsyncIterable( - client.tables.list({catalog_name: catalogName, schema_name: schemaName}) + client.volumes.list({ + catalog_name: catalogName, + schema_name: schemaName, + }) ), drainAsyncIterable( - client.volumes.list({catalog_name: catalogName, schema_name: schemaName}) + client.functions.list({ + catalog_name: catalogName, + schema_name: schemaName, + }) ), ]); @@ -223,8 +458,25 @@ export class UnityCatalogTreeDataProvider catalogName, schemaName, name: t.name!, - fullName: t.full_name ?? `${catalogName}.${schemaName}.${t.name}`, + fullName: + t.full_name ?? `${catalogName}.${schemaName}.${t.name}`, tableType: t.table_type, + comment: t.comment, + dataSourceFormat: t.data_source_format, + storageLocation: t.storage_location, + viewDefinition: t.view_definition, + owner: t.owner, + createdBy: t.created_by, + createdAt: t.created_at, + updatedAt: t.updated_at, + columns: (t.columns ?? []).map((col) => ({ + name: col.name!, + typeName: col.type_name, + typeText: col.type_text, + comment: col.comment, + nullable: col.nullable, + position: col.position, + })), })); const volumeNodes: UnityCatalogTreeNode[] = volumeRows @@ -234,23 +486,51 @@ export class UnityCatalogTreeDataProvider catalogName, schemaName, name: v.name!, - fullName: v.full_name ?? `${catalogName}.${schemaName}.${v.name}`, + fullName: + v.full_name ?? `${catalogName}.${schemaName}.${v.name}`, + volumeType: v.volume_type, + storageLocation: v.storage_location, + comment: v.comment, + owner: v.owner, })); - return [...tableNodes, ...volumeNodes].sort((a, b) => { - const an = a.kind === "table" || a.kind === "volume" ? a.name : ""; - const bn = b.kind === "table" || b.kind === "volume" ? b.name : ""; - const c = an.localeCompare(bn); - if (c !== 0) { - return c; - } - if (a.kind === b.kind) { - return 0; + const functionNodes: UnityCatalogTreeNode[] = functionRows + .filter((f) => f.name) + .map((f) => ({ + kind: "function" as const, + catalogName, + schemaName, + name: f.name!, + fullName: `${catalogName}.${schemaName}.${f.name}`, + })); + + const kindOrder = {table: 0, volume: 1, function: 2} as Record< + string, + number + >; + return [...tableNodes, ...volumeNodes, ...functionNodes].sort( + (a, b) => { + const an = + a.kind === "table" || + a.kind === "volume" || + a.kind === "function" + ? a.name + : ""; + const bn = + b.kind === "table" || + b.kind === "volume" || + b.kind === "function" + ? b.name + : ""; + const c = an.localeCompare(bn); + if (c !== 0) { + return c; + } + return (kindOrder[a.kind] ?? 0) - (kindOrder[b.kind] ?? 0); } - return a.kind === "table" ? -1 : 1; - }); + ); } catch (e) { - return this.errorChildren(e, "tables and volumes"); + return this.errorChildren(e, "tables, volumes, and functions"); } } @@ -270,6 +550,10 @@ export class UnityCatalogTreeDataProvider this._onDidChangeTreeData.fire(undefined); } + refreshNode(element: UnityCatalogTreeNode): void { + this._onDidChangeTreeData.fire(element); + } + dispose(): void { this.disposables.forEach((d) => d.dispose()); } From ef2101337d4b995ccf6eb658d7a9fa8cfd31067a Mon Sep 17 00:00:00 2001 From: misha-db Date: Tue, 31 Mar 2026 13:21:16 +0400 Subject: [PATCH 03/17] Open links of unity catalog items in Databricks --- packages/databricks-vscode/package.json | 10 ++++++++-- packages/databricks-vscode/src/extension.ts | 19 +++++++++++++++++++ .../UnityCatalogTreeDataProvider.ts | 2 +- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/databricks-vscode/package.json b/packages/databricks-vscode/package.json index 744ed5064..08e14aeb9 100644 --- a/packages/databricks-vscode/package.json +++ b/packages/databricks-vscode/package.json @@ -210,6 +210,12 @@ "icon": "$(refresh)", "category": "Databricks" }, + { + "command": "databricks.unityCatalog.openExternal", + "title": "Open in Databricks", + "icon": "$(link-external)", + "category": "Databricks" + }, { "command": "databricks.call", "title": "Call", @@ -621,12 +627,12 @@ "group": "navigation_2@0" }, { - "command": "databricks.utils.openExternal", + "command": "databricks.unityCatalog.openExternal", "when": "view == unityCatalogView && viewItem =~ /\\.has-url$/", "group": "inline@1" }, { - "command": "databricks.utils.openExternal", + "command": "databricks.unityCatalog.openExternal", "when": "view == unityCatalogView && viewItem =~ /\\.has-url$/", "group": "navigation_2@0" }, diff --git a/packages/databricks-vscode/src/extension.ts b/packages/databricks-vscode/src/extension.ts index 047281091..d09822358 100644 --- a/packages/databricks-vscode/src/extension.ts +++ b/packages/databricks-vscode/src/extension.ts @@ -27,6 +27,7 @@ import { FileUtils, PackageJsonUtils, TerraformUtils, + UrlUtils, UtilsCommands, } from "./utils"; import {ConfigureAutocomplete} from "./language/ConfigureAutocomplete"; @@ -414,6 +415,24 @@ export async function activate( await env.clipboard.writeText(node.viewDefinition); } } + ), + telemetry.registerCommand( + "databricks.unityCatalog.openExternal", + async (node: UnityCatalogTreeNode) => { + if (node.kind === "error" || node.kind === "column") { + return; + } + const url = unityCatalogTreeDataProvider.getExploreUrl( + node.fullName + ); + if (!url) { + window.showErrorMessage( + "Databricks: Can't open external link. No URL found." + ); + return; + } + await UrlUtils.openExternal(url); + } ) ); diff --git a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts index a7e48f51f..336e5b059 100644 --- a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts +++ b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts @@ -121,7 +121,7 @@ export class UnityCatalogTreeDataProvider ); } - private getExploreUrl(path: string): string | undefined { + getExploreUrl(path: string): string | undefined { const host = this.connectionManager.databricksWorkspace?.host; if (!host) { return undefined; From 56cc6bbbfa3420a204806c2db03863f45168c352 Mon Sep 17 00:00:00 2001 From: misha-db Date: Tue, 31 Mar 2026 15:58:45 +0400 Subject: [PATCH 04/17] Adjust color scheme for dark/light themes --- packages/databricks-vscode/package.json | 72 +++++++++++++++++++ .../UnityCatalogTreeDataProvider.ts | 14 ++-- 2 files changed, 79 insertions(+), 7 deletions(-) diff --git a/packages/databricks-vscode/package.json b/packages/databricks-vscode/package.json index 08e14aeb9..88ce09fb6 100644 --- a/packages/databricks-vscode/package.json +++ b/packages/databricks-vscode/package.json @@ -488,6 +488,78 @@ } ] }, + "colors": [ + { + "id": "databricks.unityCatalog.catalog", + "description": "Icon color for Unity Catalog catalog nodes", + "defaults": { + "dark": "#9B6CF7", + "light": "#6B2FD4", + "highContrast": "#C586C0", + "highContrastLight": "#6B2FD4" + } + }, + { + "id": "databricks.unityCatalog.schema", + "description": "Icon color for Unity Catalog schema nodes", + "defaults": { + "dark": "#0DB7C4", + "light": "#007A85", + "highContrast": "#4EC9B0", + "highContrastLight": "#007A85" + } + }, + { + "id": "databricks.unityCatalog.table", + "description": "Icon color for Unity Catalog table nodes", + "defaults": { + "dark": "#FF6B2C", + "light": "#C84B0A", + "highContrast": "#FF6B2C", + "highContrastLight": "#C84B0A" + } + }, + { + "id": "databricks.unityCatalog.volume", + "description": "Icon color for Unity Catalog volume nodes", + "defaults": { + "dark": "#4FC1E9", + "light": "#0E6FA0", + "highContrast": "#4FC1E9", + "highContrastLight": "#0E6FA0" + } + }, + { + "id": "databricks.unityCatalog.function", + "description": "Icon color for Unity Catalog function nodes", + "defaults": { + "dark": "#FFB347", + "light": "#A06000", + "highContrast": "#FFCA28", + "highContrastLight": "#A06000" + } + }, + { + "id": "databricks.unityCatalog.columnKey", + "description": "Icon color for Unity Catalog non-nullable (key) column nodes", + "defaults": { + "dark": "#F47C7C", + "light": "#C0392B", + "highContrast": "#F47C7C", + "highContrastLight": "#C0392B" + } + }, + { + "id": "databricks.unityCatalog.column", + "description": "Icon color for Unity Catalog nullable column nodes", + "defaults": { + "dark": "#8EAFC2", + "light": "#4A6B82", + "highContrast": "#8EAFC2", + "highContrastLight": "#4A6B82" + } + } + ], "viewsWelcome": [ { "view": "configurationView", diff --git a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts index 336e5b059..2c50c1698 100644 --- a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts +++ b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts @@ -152,7 +152,7 @@ export class UnityCatalogTreeDataProvider tooltip: tt, iconPath: new ThemeIcon( "library", - new ThemeColor("charts.purple") + new ThemeColor("databricks.unityCatalog.catalog") ), contextValue: url ? "unityCatalog.catalog.has-url" @@ -174,7 +174,7 @@ export class UnityCatalogTreeDataProvider tooltip: tt, iconPath: new ThemeIcon( "folder-library", - new ThemeColor("charts.green") + new ThemeColor("databricks.unityCatalog.schema") ), contextValue: url ? "unityCatalog.schema.has-url" @@ -215,7 +215,7 @@ export class UnityCatalogTreeDataProvider tooltip: tt, iconPath: new ThemeIcon( "package", - new ThemeColor("charts.blue") + new ThemeColor("databricks.unityCatalog.volume") ), contextValue: flags.join("."), collapsibleState: TreeItemCollapsibleState.None, @@ -232,7 +232,7 @@ export class UnityCatalogTreeDataProvider tooltip: element.fullName, iconPath: new ThemeIcon( "symbol-function", - new ThemeColor("charts.yellow") + new ThemeColor("databricks.unityCatalog.function") ), contextValue: url ? "unityCatalog.function.has-url" @@ -246,8 +246,8 @@ export class UnityCatalogTreeDataProvider if (element.kind === "column") { const icon = element.nullable === false - ? new ThemeIcon("symbol-key") - : new ThemeIcon("symbol-field"); + ? new ThemeIcon("symbol-key", new ThemeColor("databricks.unityCatalog.columnKey")) + : new ThemeIcon("symbol-field", new ThemeColor("databricks.unityCatalog.column")); const typeLabel = element.typeText ?? element.typeName ?? ""; const tt = new MarkdownString( `**${element.name}** \`${typeLabel}\`` @@ -319,7 +319,7 @@ export class UnityCatalogTreeDataProvider label: `${element.name}${typeSuffix}`, description: element.dataSourceFormat, tooltip: tt, - iconPath: new ThemeIcon("table", new ThemeColor("charts.orange")), + iconPath: new ThemeIcon("table", new ThemeColor("databricks.unityCatalog.table")), contextValue: flags.join("."), collapsibleState: hasColumns ? TreeItemCollapsibleState.Collapsed From 5569d33fd77f2a9307deadb6fd266f72463d5ea6 Mon Sep 17 00:00:00 2001 From: misha-db Date: Tue, 31 Mar 2026 16:21:30 +0400 Subject: [PATCH 05/17] Fix explore url --- .../UnityCatalogTreeDataProvider.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts index 2c50c1698..ed4e8c588 100644 --- a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts +++ b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts @@ -121,11 +121,12 @@ export class UnityCatalogTreeDataProvider ); } - getExploreUrl(path: string): string | undefined { + getExploreUrl(fullName: string): string | undefined { const host = this.connectionManager.databricksWorkspace?.host; if (!host) { return undefined; } + const path = fullName.replaceAll(".", "/"); return `${host.toString()}explore/data/${path}`; } @@ -246,8 +247,14 @@ export class UnityCatalogTreeDataProvider if (element.kind === "column") { const icon = element.nullable === false - ? new ThemeIcon("symbol-key", new ThemeColor("databricks.unityCatalog.columnKey")) - : new ThemeIcon("symbol-field", new ThemeColor("databricks.unityCatalog.column")); + ? new ThemeIcon( + "symbol-key", + new ThemeColor("databricks.unityCatalog.columnKey") + ) + : new ThemeIcon( + "symbol-field", + new ThemeColor("databricks.unityCatalog.column") + ); const typeLabel = element.typeText ?? element.typeName ?? ""; const tt = new MarkdownString( `**${element.name}** \`${typeLabel}\`` @@ -319,7 +326,10 @@ export class UnityCatalogTreeDataProvider label: `${element.name}${typeSuffix}`, description: element.dataSourceFormat, tooltip: tt, - iconPath: new ThemeIcon("table", new ThemeColor("databricks.unityCatalog.table")), + iconPath: new ThemeIcon( + "table", + new ThemeColor("databricks.unityCatalog.table") + ), contextValue: flags.join("."), collapsibleState: hasColumns ? TreeItemCollapsibleState.Collapsed From 8f2013e7e5aee345ff56b6ead5b5a3326f7ec548 Mon Sep 17 00:00:00 2001 From: misha-db Date: Tue, 31 Mar 2026 18:29:32 +0400 Subject: [PATCH 06/17] Fix function explore url --- packages/databricks-vscode/src/extension.ts | 5 ++-- .../UnityCatalogTreeDataProvider.ts | 25 +++++++++++++------ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/databricks-vscode/src/extension.ts b/packages/databricks-vscode/src/extension.ts index d09822358..9bf11ba6a 100644 --- a/packages/databricks-vscode/src/extension.ts +++ b/packages/databricks-vscode/src/extension.ts @@ -422,9 +422,8 @@ export async function activate( if (node.kind === "error" || node.kind === "column") { return; } - const url = unityCatalogTreeDataProvider.getExploreUrl( - node.fullName - ); + const url = + unityCatalogTreeDataProvider.getNodeExploreUrl(node); if (!url) { window.showErrorMessage( "Databricks: Can't open external link. No URL found." diff --git a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts index ed4e8c588..f1ea49cf5 100644 --- a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts +++ b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts @@ -121,15 +121,26 @@ export class UnityCatalogTreeDataProvider ); } - getExploreUrl(fullName: string): string | undefined { + private getExploreUrl(path: string): string | undefined { const host = this.connectionManager.databricksWorkspace?.host; if (!host) { return undefined; } - const path = fullName.replaceAll(".", "/"); return `${host.toString()}explore/data/${path}`; } + getNodeExploreUrl(node: UnityCatalogTreeNode): string | undefined { + if (node.kind === "error" || node.kind === "column") { + return undefined; + } + const fullNamePath = node.fullName.replaceAll(".", "/"); + const path = + node.kind === "function" + ? `functions/${fullNamePath}` + : fullNamePath; + return this.getExploreUrl(path); + } + getTreeItem(element: UnityCatalogTreeNode): UnityCatalogTreeItem { if (element.kind === "error") { return { @@ -143,7 +154,7 @@ export class UnityCatalogTreeDataProvider } if (element.kind === "catalog") { - const url = this.getExploreUrl(element.fullName); + const url = this.getNodeExploreUrl(element); const tt = new MarkdownString(`**${element.fullName}**`); if (element.comment) { tt.appendMarkdown(`\n\n${element.comment}`); @@ -165,7 +176,7 @@ export class UnityCatalogTreeDataProvider } if (element.kind === "schema") { - const url = this.getExploreUrl(element.fullName); + const url = this.getNodeExploreUrl(element); const tt = new MarkdownString(`**${element.fullName}**`); if (element.comment) { tt.appendMarkdown(`\n\n${element.comment}`); @@ -187,7 +198,7 @@ export class UnityCatalogTreeDataProvider } if (element.kind === "volume") { - const url = this.getExploreUrl(element.fullName); + const url = this.getNodeExploreUrl(element); const isExternal = element.volumeType !== undefined && element.volumeType !== "MANAGED"; @@ -227,7 +238,7 @@ export class UnityCatalogTreeDataProvider } if (element.kind === "function") { - const url = this.getExploreUrl(element.fullName); + const url = this.getNodeExploreUrl(element); return { label: element.name, tooltip: element.fullName, @@ -281,7 +292,7 @@ export class UnityCatalogTreeDataProvider element.tableType && element.tableType !== "MANAGED" ? ` (${element.tableType})` : ""; - const url = this.getExploreUrl(element.fullName); + const url = this.getNodeExploreUrl(element); const flags = ["unityCatalog.table"]; if (url) { flags.push("has-url"); From f1cb4588254d3e94b6041b1dc43c8c4cc9a26bd1 Mon Sep 17 00:00:00 2001 From: misha-db Date: Tue, 31 Mar 2026 18:40:42 +0400 Subject: [PATCH 07/17] Allow explore external links for tables and volumes --- packages/databricks-vscode/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/databricks-vscode/package.json b/packages/databricks-vscode/package.json index 88ce09fb6..3df65be89 100644 --- a/packages/databricks-vscode/package.json +++ b/packages/databricks-vscode/package.json @@ -700,12 +700,12 @@ }, { "command": "databricks.unityCatalog.openExternal", - "when": "view == unityCatalogView && viewItem =~ /\\.has-url$/", + "when": "view == unityCatalogView && viewItem =~ /\\.has-url/", "group": "inline@1" }, { "command": "databricks.unityCatalog.openExternal", - "when": "view == unityCatalogView && viewItem =~ /\\.has-url$/", + "when": "view == unityCatalogView && viewItem =~ /\\.has-url/", "group": "navigation_2@0" }, { From 26eb963fdc0845bff5f55ad85b185f0be03b6f45 Mon Sep 17 00:00:00 2001 From: misha-db Date: Tue, 31 Mar 2026 19:14:56 +0400 Subject: [PATCH 08/17] Fix copy for nodes --- packages/databricks-vscode/package.json | 12 +++++++++++- packages/databricks-vscode/src/extension.ts | 11 +++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/databricks-vscode/package.json b/packages/databricks-vscode/package.json index 3df65be89..3787cd1e8 100644 --- a/packages/databricks-vscode/package.json +++ b/packages/databricks-vscode/package.json @@ -204,6 +204,11 @@ "title": "Copy view SQL", "category": "Databricks" }, + { + "command": "databricks.unityCatalog.copyName", + "title": "Copy", + "category": "Databricks" + }, { "command": "databricks.unityCatalog.refreshNode", "title": "Refresh", @@ -708,6 +713,11 @@ "when": "view == unityCatalogView && viewItem =~ /\\.has-url/", "group": "navigation_2@0" }, + { + "command": "databricks.unityCatalog.copyName", + "when": "view == unityCatalogView && viewItem =~ /unityCatalog/", + "group": "navigation_2@0" + }, { "command": "databricks.unityCatalog.copyStorageLocation", "when": "view == unityCatalogView && viewItem =~ /\\.has-storage/", @@ -865,7 +875,7 @@ }, { "command": "databricks.utils.copy", - "when": "view == dabsResourceExplorerView || view == configurationView || view == unityCatalogView", + "when": "view == dabsResourceExplorerView || view == configurationView", "group": "navigation_2@0" }, { diff --git a/packages/databricks-vscode/src/extension.ts b/packages/databricks-vscode/src/extension.ts index 9bf11ba6a..a3b07a62d 100644 --- a/packages/databricks-vscode/src/extension.ts +++ b/packages/databricks-vscode/src/extension.ts @@ -416,6 +416,17 @@ export async function activate( } } ), + telemetry.registerCommand( + "databricks.unityCatalog.copyName", + async (node: UnityCatalogTreeNode) => { + if (node.kind === "error") { + return; + } + const text = node.kind === "column" ? node.name : node.fullName; + await env.clipboard.writeText(text); + window.showInformationMessage("Copied to clipboard"); + } + ), telemetry.registerCommand( "databricks.unityCatalog.openExternal", async (node: UnityCatalogTreeNode) => { From 41812130491a88c7505e9618cd09027208435311 Mon Sep 17 00:00:00 2001 From: misha-db Date: Tue, 31 Mar 2026 19:37:09 +0400 Subject: [PATCH 09/17] Refactor and do cleanup --- .../UnityCatalogTreeDataProvider.ts | 309 +----------------- .../src/ui/unity-catalog/nodeRenderer.ts | 256 +++++++++++++++ .../src/ui/unity-catalog/types.ts | 73 +++++ 3 files changed, 339 insertions(+), 299 deletions(-) create mode 100644 packages/databricks-vscode/src/ui/unity-catalog/nodeRenderer.ts create mode 100644 packages/databricks-vscode/src/ui/unity-catalog/types.ts diff --git a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts index f1ea49cf5..d59aa6179 100644 --- a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts +++ b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts @@ -1,91 +1,18 @@ /* eslint-disable @typescript-eslint/naming-convention */ import {ApiError, logging} from "@databricks/sdk-experimental"; -import { - Disposable, - EventEmitter, - MarkdownString, - ThemeColor, - ThemeIcon, - TreeDataProvider, - TreeItem, - TreeItemCollapsibleState, -} from "vscode"; +import {Disposable, EventEmitter, TreeDataProvider} from "vscode"; import {ConnectionManager} from "../../configuration/ConnectionManager"; import {Loggers} from "../../logger"; +import {buildTreeItem} from "./nodeRenderer"; +import {UnityCatalogTreeItem, UnityCatalogTreeNode} from "./types"; -const logger = logging.NamedLogger.getOrCreate(Loggers.Extension); - -export interface ColumnData { - name: string; - typeName?: string; - typeText?: string; - comment?: string; - nullable?: boolean; - position?: number; -} - -export type UnityCatalogTreeNode = - | {kind: "catalog"; name: string; fullName: string; comment?: string} - | { - kind: "schema"; - catalogName: string; - name: string; - fullName: string; - comment?: string; - } - | { - kind: "table"; - catalogName: string; - schemaName: string; - name: string; - fullName: string; - tableType?: string; - comment?: string; - dataSourceFormat?: string; - storageLocation?: string; - viewDefinition?: string; - owner?: string; - createdBy?: string; - createdAt?: number; - updatedAt?: number; - columns?: ColumnData[]; - } - | { - kind: "volume"; - catalogName: string; - schemaName: string; - name: string; - fullName: string; - volumeType?: string; - storageLocation?: string; - comment?: string; - owner?: string; - } - | { - kind: "function"; - catalogName: string; - schemaName: string; - name: string; - fullName: string; - } - | { - kind: "column"; - tableFullName: string; - name: string; - typeName?: string; - typeText?: string; - comment?: string; - nullable?: boolean; - position?: number; - } - | {kind: "error"; message: string}; +export type { + ColumnData, + UnityCatalogTreeItem, + UnityCatalogTreeNode, +} from "./types"; -export interface UnityCatalogTreeItem extends TreeItem { - url?: string; - copyText?: string; - storageLocation?: string; - viewDefinition?: string; -} +const logger = logging.NamedLogger.getOrCreate(Loggers.Extension); async function drainAsyncIterable(iter: AsyncIterable): Promise { const out: T[] = []; @@ -95,15 +22,6 @@ async function drainAsyncIterable(iter: AsyncIterable): Promise { return out; } -function formatTs(ms: number | undefined): string | undefined { - if (ms === undefined) { - return undefined; - } - return ( - new Date(ms).toISOString().replace("T", " ").substring(0, 19) + " UTC" - ); -} - export class UnityCatalogTreeDataProvider implements TreeDataProvider, Disposable { @@ -142,214 +60,7 @@ export class UnityCatalogTreeDataProvider } getTreeItem(element: UnityCatalogTreeNode): UnityCatalogTreeItem { - if (element.kind === "error") { - return { - label: element.message, - iconPath: new ThemeIcon( - "error", - new ThemeColor("notificationsErrorIcon.foreground") - ), - collapsibleState: TreeItemCollapsibleState.None, - }; - } - - if (element.kind === "catalog") { - const url = this.getNodeExploreUrl(element); - const tt = new MarkdownString(`**${element.fullName}**`); - if (element.comment) { - tt.appendMarkdown(`\n\n${element.comment}`); - } - return { - label: element.name, - tooltip: tt, - iconPath: new ThemeIcon( - "library", - new ThemeColor("databricks.unityCatalog.catalog") - ), - contextValue: url - ? "unityCatalog.catalog.has-url" - : "unityCatalog.catalog", - collapsibleState: TreeItemCollapsibleState.Collapsed, - url, - copyText: element.fullName, - }; - } - - if (element.kind === "schema") { - const url = this.getNodeExploreUrl(element); - const tt = new MarkdownString(`**${element.fullName}**`); - if (element.comment) { - tt.appendMarkdown(`\n\n${element.comment}`); - } - return { - label: element.name, - tooltip: tt, - iconPath: new ThemeIcon( - "folder-library", - new ThemeColor("databricks.unityCatalog.schema") - ), - contextValue: url - ? "unityCatalog.schema.has-url" - : "unityCatalog.schema", - collapsibleState: TreeItemCollapsibleState.Collapsed, - url, - copyText: element.fullName, - }; - } - - if (element.kind === "volume") { - const url = this.getNodeExploreUrl(element); - const isExternal = - element.volumeType !== undefined && - element.volumeType !== "MANAGED"; - const label = isExternal - ? `${element.name} (${element.volumeType})` - : element.name; - const flags = ["unityCatalog.volume"]; - if (url) { - flags.push("has-url"); - } - if (element.storageLocation) { - flags.push("has-storage"); - } - const tt = new MarkdownString(`**${element.fullName}**`); - if (element.volumeType) { - tt.appendMarkdown(`\n\n*Type:* ${element.volumeType}`); - } - if (element.owner) { - tt.appendMarkdown(`\n\n*Owner:* ${element.owner}`); - } - if (element.comment) { - tt.appendMarkdown(`\n\n${element.comment}`); - } - return { - label, - tooltip: tt, - iconPath: new ThemeIcon( - "package", - new ThemeColor("databricks.unityCatalog.volume") - ), - contextValue: flags.join("."), - collapsibleState: TreeItemCollapsibleState.None, - url, - copyText: element.fullName, - storageLocation: element.storageLocation, - }; - } - - if (element.kind === "function") { - const url = this.getNodeExploreUrl(element); - return { - label: element.name, - tooltip: element.fullName, - iconPath: new ThemeIcon( - "symbol-function", - new ThemeColor("databricks.unityCatalog.function") - ), - contextValue: url - ? "unityCatalog.function.has-url" - : "unityCatalog.function", - collapsibleState: TreeItemCollapsibleState.None, - url, - copyText: element.fullName, - }; - } - - if (element.kind === "column") { - const icon = - element.nullable === false - ? new ThemeIcon( - "symbol-key", - new ThemeColor("databricks.unityCatalog.columnKey") - ) - : new ThemeIcon( - "symbol-field", - new ThemeColor("databricks.unityCatalog.column") - ); - const typeLabel = element.typeText ?? element.typeName ?? ""; - const tt = new MarkdownString( - `**${element.name}** \`${typeLabel}\`` - ); - if (element.nullable === false) { - tt.appendMarkdown(" *(not null)*"); - } - if (element.comment) { - tt.appendMarkdown(`\n\n${element.comment}`); - } - return { - label: element.name, - description: typeLabel, - tooltip: tt, - iconPath: icon, - contextValue: "unityCatalog.column", - collapsibleState: TreeItemCollapsibleState.None, - copyText: element.name, - }; - } - - // table - const typeSuffix = - element.tableType && element.tableType !== "MANAGED" - ? ` (${element.tableType})` - : ""; - const url = this.getNodeExploreUrl(element); - const flags = ["unityCatalog.table"]; - if (url) { - flags.push("has-url"); - } - if (element.storageLocation) { - flags.push("has-storage"); - } - const isView = - element.tableType === "VIEW" || - element.tableType === "MATERIALIZED_VIEW"; - if (isView && element.viewDefinition) { - flags.push("is-view"); - } - - const tt = new MarkdownString(`**${element.fullName}**`); - if (element.tableType) { - tt.appendMarkdown(`\n\n*Type:* ${element.tableType}`); - } - if (element.dataSourceFormat) { - tt.appendMarkdown(` · *Format:* ${element.dataSourceFormat}`); - } - if (element.owner) { - tt.appendMarkdown(`\n\n*Owner:* ${element.owner}`); - } - if (element.createdBy) { - tt.appendMarkdown(` · *Created by:* ${element.createdBy}`); - } - const cAt = formatTs(element.createdAt); - const uAt = formatTs(element.updatedAt); - if (cAt) { - tt.appendMarkdown(`\n\n*Created:* ${cAt}`); - } - if (uAt) { - tt.appendMarkdown(` *Updated:* ${uAt}`); - } - if (element.comment) { - tt.appendMarkdown(`\n\n${element.comment}`); - } - - const hasColumns = (element.columns?.length ?? 0) > 0; - return { - label: `${element.name}${typeSuffix}`, - description: element.dataSourceFormat, - tooltip: tt, - iconPath: new ThemeIcon( - "table", - new ThemeColor("databricks.unityCatalog.table") - ), - contextValue: flags.join("."), - collapsibleState: hasColumns - ? TreeItemCollapsibleState.Collapsed - : TreeItemCollapsibleState.None, - url, - copyText: element.fullName, - storageLocation: element.storageLocation, - viewDefinition: element.viewDefinition, - }; + return buildTreeItem(element, this.getNodeExploreUrl(element)); } getChildren( diff --git a/packages/databricks-vscode/src/ui/unity-catalog/nodeRenderer.ts b/packages/databricks-vscode/src/ui/unity-catalog/nodeRenderer.ts new file mode 100644 index 000000000..b4caa212a --- /dev/null +++ b/packages/databricks-vscode/src/ui/unity-catalog/nodeRenderer.ts @@ -0,0 +1,256 @@ +import { + MarkdownString, + ThemeColor, + ThemeIcon, + TreeItemCollapsibleState, +} from "vscode"; +import {UnityCatalogTreeNode, UnityCatalogTreeItem} from "./types"; + +function formatTs(ms: number | undefined): string | undefined { + if (ms === undefined) { + return undefined; + } + return ( + new Date(ms).toISOString().replace("T", " ").substring(0, 19) + " UTC" + ); +} + +function renderError( + node: Extract +): UnityCatalogTreeItem { + return { + label: node.message, + iconPath: new ThemeIcon( + "error", + new ThemeColor("notificationsErrorIcon.foreground") + ), + collapsibleState: TreeItemCollapsibleState.None, + }; +} + +function renderCatalog( + node: Extract, + exploreUrl: string | undefined +): UnityCatalogTreeItem { + const tt = new MarkdownString(`**${node.fullName}**`); + if (node.comment) { + tt.appendMarkdown(`\n\n${node.comment}`); + } + return { + label: node.name, + tooltip: tt, + iconPath: new ThemeIcon( + "library", + new ThemeColor("databricks.unityCatalog.catalog") + ), + contextValue: exploreUrl + ? "unityCatalog.catalog.has-url" + : "unityCatalog.catalog", + collapsibleState: TreeItemCollapsibleState.Collapsed, + url: exploreUrl, + copyText: node.fullName, + }; +} + +function renderSchema( + node: Extract, + exploreUrl: string | undefined +): UnityCatalogTreeItem { + const tt = new MarkdownString(`**${node.fullName}**`); + if (node.comment) { + tt.appendMarkdown(`\n\n${node.comment}`); + } + return { + label: node.name, + tooltip: tt, + iconPath: new ThemeIcon( + "folder-library", + new ThemeColor("databricks.unityCatalog.schema") + ), + contextValue: exploreUrl + ? "unityCatalog.schema.has-url" + : "unityCatalog.schema", + collapsibleState: TreeItemCollapsibleState.Collapsed, + url: exploreUrl, + copyText: node.fullName, + }; +} + +function renderTable( + node: Extract, + exploreUrl: string | undefined +): UnityCatalogTreeItem { + const typeSuffix = + node.tableType && node.tableType !== "MANAGED" + ? ` (${node.tableType})` + : ""; + const flags = ["unityCatalog.table"]; + if (exploreUrl) { + flags.push("has-url"); + } + if (node.storageLocation) { + flags.push("has-storage"); + } + const isView = + node.tableType === "VIEW" || node.tableType === "MATERIALIZED_VIEW"; + if (isView && node.viewDefinition) { + flags.push("is-view"); + } + + const tt = new MarkdownString(`**${node.fullName}**`); + if (node.tableType) { + tt.appendMarkdown(`\n\n*Type:* ${node.tableType}`); + } + if (node.dataSourceFormat) { + tt.appendMarkdown(` · *Format:* ${node.dataSourceFormat}`); + } + if (node.owner) { + tt.appendMarkdown(`\n\n*Owner:* ${node.owner}`); + } + if (node.createdBy) { + tt.appendMarkdown(` · *Created by:* ${node.createdBy}`); + } + const cAt = formatTs(node.createdAt); + const uAt = formatTs(node.updatedAt); + if (cAt) { + tt.appendMarkdown(`\n\n*Created:* ${cAt}`); + } + if (uAt) { + tt.appendMarkdown(` *Updated:* ${uAt}`); + } + if (node.comment) { + tt.appendMarkdown(`\n\n${node.comment}`); + } + + const hasColumns = (node.columns?.length ?? 0) > 0; + return { + label: `${node.name}${typeSuffix}`, + description: node.dataSourceFormat, + tooltip: tt, + iconPath: new ThemeIcon( + "table", + new ThemeColor("databricks.unityCatalog.table") + ), + contextValue: flags.join("."), + collapsibleState: hasColumns + ? TreeItemCollapsibleState.Collapsed + : TreeItemCollapsibleState.None, + url: exploreUrl, + copyText: node.fullName, + storageLocation: node.storageLocation, + viewDefinition: node.viewDefinition, + }; +} + +function renderVolume( + node: Extract, + exploreUrl: string | undefined +): UnityCatalogTreeItem { + const isExternal = + node.volumeType !== undefined && node.volumeType !== "MANAGED"; + const label = isExternal ? `${node.name} (${node.volumeType})` : node.name; + const flags = ["unityCatalog.volume"]; + if (exploreUrl) { + flags.push("has-url"); + } + if (node.storageLocation) { + flags.push("has-storage"); + } + const tt = new MarkdownString(`**${node.fullName}**`); + if (node.volumeType) { + tt.appendMarkdown(`\n\n*Type:* ${node.volumeType}`); + } + if (node.owner) { + tt.appendMarkdown(`\n\n*Owner:* ${node.owner}`); + } + if (node.comment) { + tt.appendMarkdown(`\n\n${node.comment}`); + } + return { + label, + tooltip: tt, + iconPath: new ThemeIcon( + "package", + new ThemeColor("databricks.unityCatalog.volume") + ), + contextValue: flags.join("."), + collapsibleState: TreeItemCollapsibleState.None, + url: exploreUrl, + copyText: node.fullName, + storageLocation: node.storageLocation, + }; +} + +function renderFunction( + node: Extract, + exploreUrl: string | undefined +): UnityCatalogTreeItem { + return { + label: node.name, + tooltip: node.fullName, + iconPath: new ThemeIcon( + "symbol-function", + new ThemeColor("databricks.unityCatalog.function") + ), + contextValue: exploreUrl + ? "unityCatalog.function.has-url" + : "unityCatalog.function", + collapsibleState: TreeItemCollapsibleState.None, + url: exploreUrl, + copyText: node.fullName, + }; +} + +function renderColumn( + node: Extract +): UnityCatalogTreeItem { + const icon = + node.nullable === false + ? new ThemeIcon( + "symbol-key", + new ThemeColor("databricks.unityCatalog.columnKey") + ) + : new ThemeIcon( + "symbol-field", + new ThemeColor("databricks.unityCatalog.column") + ); + const typeLabel = node.typeText ?? node.typeName ?? ""; + const tt = new MarkdownString(`**${node.name}** \`${typeLabel}\``); + if (node.nullable === false) { + tt.appendMarkdown(" *(not null)*"); + } + if (node.comment) { + tt.appendMarkdown(`\n\n${node.comment}`); + } + return { + label: node.name, + description: typeLabel, + tooltip: tt, + iconPath: icon, + contextValue: "unityCatalog.column", + collapsibleState: TreeItemCollapsibleState.None, + copyText: node.name, + }; +} + +export function buildTreeItem( + node: UnityCatalogTreeNode, + exploreUrl: string | undefined +): UnityCatalogTreeItem { + switch (node.kind) { + case "error": + return renderError(node); + case "catalog": + return renderCatalog(node, exploreUrl); + case "schema": + return renderSchema(node, exploreUrl); + case "table": + return renderTable(node, exploreUrl); + case "volume": + return renderVolume(node, exploreUrl); + case "function": + return renderFunction(node, exploreUrl); + case "column": + return renderColumn(node); + } +} diff --git a/packages/databricks-vscode/src/ui/unity-catalog/types.ts b/packages/databricks-vscode/src/ui/unity-catalog/types.ts new file mode 100644 index 000000000..461e5812a --- /dev/null +++ b/packages/databricks-vscode/src/ui/unity-catalog/types.ts @@ -0,0 +1,73 @@ +import {TreeItem} from "vscode"; + +export interface ColumnData { + name: string; + typeName?: string; + typeText?: string; + comment?: string; + nullable?: boolean; + position?: number; +} + +export type UnityCatalogTreeNode = + | {kind: "catalog"; name: string; fullName: string; comment?: string} + | { + kind: "schema"; + catalogName: string; + name: string; + fullName: string; + comment?: string; + } + | { + kind: "table"; + catalogName: string; + schemaName: string; + name: string; + fullName: string; + tableType?: string; + comment?: string; + dataSourceFormat?: string; + storageLocation?: string; + viewDefinition?: string; + owner?: string; + createdBy?: string; + createdAt?: number; + updatedAt?: number; + columns?: ColumnData[]; + } + | { + kind: "volume"; + catalogName: string; + schemaName: string; + name: string; + fullName: string; + volumeType?: string; + storageLocation?: string; + comment?: string; + owner?: string; + } + | { + kind: "function"; + catalogName: string; + schemaName: string; + name: string; + fullName: string; + } + | { + kind: "column"; + tableFullName: string; + name: string; + typeName?: string; + typeText?: string; + comment?: string; + nullable?: boolean; + position?: number; + } + | {kind: "error"; message: string}; + +export interface UnityCatalogTreeItem extends TreeItem { + url?: string; + copyText?: string; + storageLocation?: string; + viewDefinition?: string; +} From dd417b0a0ace63a22168eb31031a0836fe5f0dd4 Mon Sep 17 00:00:00 2001 From: misha-db Date: Thu, 2 Apr 2026 18:33:28 +0400 Subject: [PATCH 10/17] Add search item icon. Show empty node when no children returned. --- packages/databricks-vscode/package.json | 11 ++++++++++ packages/databricks-vscode/src/extension.ts | 9 ++++++++- .../UnityCatalogTreeDataProvider.ts | 20 +++++++++++++++---- .../src/ui/unity-catalog/nodeRenderer.ts | 15 ++++++++++++++ .../src/ui/unity-catalog/types.ts | 3 ++- 5 files changed, 52 insertions(+), 6 deletions(-) diff --git a/packages/databricks-vscode/package.json b/packages/databricks-vscode/package.json index 3787cd1e8..2384ab9d6 100644 --- a/packages/databricks-vscode/package.json +++ b/packages/databricks-vscode/package.json @@ -187,6 +187,12 @@ "enablement": "databricks.context.activated && databricks.context.loggedIn && databricks.feature.views.workspace", "category": "Databricks" }, + { + "command": "databricks.unityCatalog.filter", + "title": "Filter", + "icon": "$(search)", + "category": "Databricks" + }, { "command": "databricks.unityCatalog.refresh", "title": "Refresh Unity Catalog view", @@ -642,6 +648,11 @@ "when": "view == workspaceFsView", "group": "navigation@1" }, + { + "command": "databricks.unityCatalog.filter", + "when": "view == unityCatalogView", + "group": "navigation@2" + }, { "command": "databricks.unityCatalog.refresh", "when": "view == unityCatalogView", diff --git a/packages/databricks-vscode/src/extension.ts b/packages/databricks-vscode/src/extension.ts index a3b07a62d..433ddd3bd 100644 --- a/packages/databricks-vscode/src/extension.ts +++ b/packages/databricks-vscode/src/extension.ts @@ -419,7 +419,7 @@ export async function activate( telemetry.registerCommand( "databricks.unityCatalog.copyName", async (node: UnityCatalogTreeNode) => { - if (node.kind === "error") { + if (node.kind === "error" || node.kind === "empty") { return; } const text = node.kind === "column" ? node.name : node.fullName; @@ -443,6 +443,13 @@ export async function activate( } await UrlUtils.openExternal(url); } + ), + commands.registerCommand( + "databricks.unityCatalog.filter", + async () => { + await commands.executeCommand("unityCatalogView.focus"); + await commands.executeCommand("list.find"); + } ) ); diff --git a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts index d59aa6179..dbbdbfce1 100644 --- a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts +++ b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts @@ -48,7 +48,7 @@ export class UnityCatalogTreeDataProvider } getNodeExploreUrl(node: UnityCatalogTreeNode): string | undefined { - if (node.kind === "error" || node.kind === "column") { + if (node.kind === "error" || node.kind === "column" || node.kind === "empty") { return undefined; } const fullNamePath = node.fullName.replaceAll(".", "/"); @@ -119,7 +119,7 @@ export class UnityCatalogTreeDataProvider ): Promise { try { const rows = await drainAsyncIterable(client.catalogs.list({})); - return rows + const result = rows .filter((c) => c.name) .map((c) => ({ kind: "catalog" as const, @@ -128,6 +128,9 @@ export class UnityCatalogTreeDataProvider comment: c.comment, })) .sort((a, b) => a.name.localeCompare(b.name)); + return result.length > 0 + ? result + : this.emptyNode("No catalogs found"); } catch (e) { return this.errorChildren(e, "catalogs"); } @@ -141,7 +144,7 @@ export class UnityCatalogTreeDataProvider const rows = await drainAsyncIterable( client.schemas.list({catalog_name: catalogName}) ); - return rows + const result = rows .filter((s) => s.name) .map((s) => ({ kind: "schema" as const, @@ -151,6 +154,7 @@ export class UnityCatalogTreeDataProvider comment: s.comment, })) .sort((a, b) => a.name.localeCompare(b.name)); + return result.length > 0 ? result : this.emptyNode("No schemas"); } catch (e) { return this.errorChildren(e, "schemas"); } @@ -240,7 +244,11 @@ export class UnityCatalogTreeDataProvider string, number >; - return [...tableNodes, ...volumeNodes, ...functionNodes].sort( + const combined = [...tableNodes, ...volumeNodes, ...functionNodes]; + if (combined.length === 0) { + return this.emptyNode("No items"); + } + return combined.sort( (a, b) => { const an = a.kind === "table" || @@ -266,6 +274,10 @@ export class UnityCatalogTreeDataProvider } } + private emptyNode(message: string): UnityCatalogTreeNode[] { + return [{kind: "empty", message}]; + } + private errorChildren( e: unknown, resource: string diff --git a/packages/databricks-vscode/src/ui/unity-catalog/nodeRenderer.ts b/packages/databricks-vscode/src/ui/unity-catalog/nodeRenderer.ts index b4caa212a..d8edb9ab8 100644 --- a/packages/databricks-vscode/src/ui/unity-catalog/nodeRenderer.ts +++ b/packages/databricks-vscode/src/ui/unity-catalog/nodeRenderer.ts @@ -28,6 +28,19 @@ function renderError( }; } +function renderEmpty( + node: Extract +): UnityCatalogTreeItem { + return { + label: node.message, + iconPath: new ThemeIcon( + "info", + new ThemeColor("descriptionForeground") + ), + collapsibleState: TreeItemCollapsibleState.None, + }; +} + function renderCatalog( node: Extract, exploreUrl: string | undefined @@ -240,6 +253,8 @@ export function buildTreeItem( switch (node.kind) { case "error": return renderError(node); + case "empty": + return renderEmpty(node); case "catalog": return renderCatalog(node, exploreUrl); case "schema": diff --git a/packages/databricks-vscode/src/ui/unity-catalog/types.ts b/packages/databricks-vscode/src/ui/unity-catalog/types.ts index 461e5812a..fd2f873a7 100644 --- a/packages/databricks-vscode/src/ui/unity-catalog/types.ts +++ b/packages/databricks-vscode/src/ui/unity-catalog/types.ts @@ -63,7 +63,8 @@ export type UnityCatalogTreeNode = nullable?: boolean; position?: number; } - | {kind: "error"; message: string}; + | {kind: "error"; message: string} + | {kind: "empty"; message: string}; export interface UnityCatalogTreeItem extends TreeItem { url?: string; From c9981d0710beb14c0ab8efdfe618c9886019bd7c Mon Sep 17 00:00:00 2001 From: misha-db Date: Thu, 2 Apr 2026 20:13:17 +0400 Subject: [PATCH 11/17] Pin/Unpin schema --- packages/databricks-vscode/package.json | 32 ++++++++++ packages/databricks-vscode/src/extension.ts | 19 +++++- .../UnityCatalogTreeDataProvider.test.ts | 60 +++++++++++++------ .../UnityCatalogTreeDataProvider.ts | 52 +++++++++++++++- .../src/ui/unity-catalog/nodeRenderer.ts | 10 +++- .../src/ui/unity-catalog/types.ts | 1 + .../src/vscode-objs/StateStorage.ts | 5 ++ 7 files changed, 155 insertions(+), 24 deletions(-) diff --git a/packages/databricks-vscode/package.json b/packages/databricks-vscode/package.json index 2384ab9d6..e3798bec9 100644 --- a/packages/databricks-vscode/package.json +++ b/packages/databricks-vscode/package.json @@ -221,6 +221,18 @@ "icon": "$(refresh)", "category": "Databricks" }, + { + "command": "databricks.unityCatalog.pinSchema", + "title": "Pin Schema", + "icon": "$(star-empty)", + "category": "Databricks" + }, + { + "command": "databricks.unityCatalog.unpinSchema", + "title": "Unpin Schema", + "icon": "$(star-full)", + "category": "Databricks" + }, { "command": "databricks.unityCatalog.openExternal", "title": "Open in Databricks", @@ -749,6 +761,26 @@ "when": "view == unityCatalogView && viewItem =~ /^unityCatalog\\.(?!column)/", "group": "inline@2" }, + { + "command": "databricks.unityCatalog.pinSchema", + "when": "view == unityCatalogView && viewItem =~ /^unityCatalog\\.schema/ && !(viewItem =~ /\\.is-pinned/)", + "group": "inline@3" + }, + { + "command": "databricks.unityCatalog.unpinSchema", + "when": "view == unityCatalogView && viewItem =~ /^unityCatalog\\.schema.*\\.is-pinned/", + "group": "inline@3" + }, + { + "command": "databricks.unityCatalog.pinSchema", + "when": "view == unityCatalogView && viewItem =~ /^unityCatalog\\.schema/ && !(viewItem =~ /\\.is-pinned/)", + "group": "navigation_2@4" + }, + { + "command": "databricks.unityCatalog.unpinSchema", + "when": "view == unityCatalogView && viewItem =~ /^unityCatalog\\.schema.*\\.is-pinned/", + "group": "navigation_2@4" + }, { "command": "databricks.utils.goToDefinition", "when": "viewItem =~ /^databricks.*\\.(has-source-location).*$/", diff --git a/packages/databricks-vscode/src/extension.ts b/packages/databricks-vscode/src/extension.ts index 433ddd3bd..c75d6725a 100644 --- a/packages/databricks-vscode/src/extension.ts +++ b/packages/databricks-vscode/src/extension.ts @@ -377,7 +377,8 @@ export async function activate( ); const unityCatalogTreeDataProvider = new UnityCatalogTreeDataProvider( - connectionManager + connectionManager, + stateStorage ); const unityCatalogTreeView = window.createTreeView("unityCatalogView", { treeDataProvider: unityCatalogTreeDataProvider, @@ -450,6 +451,22 @@ export async function activate( await commands.executeCommand("unityCatalogView.focus"); await commands.executeCommand("list.find"); } + ), + telemetry.registerCommand( + "databricks.unityCatalog.pinSchema", + (node: UnityCatalogTreeNode) => { + if (node.kind === "schema") { + return unityCatalogTreeDataProvider.pinSchema(node); + } + } + ), + telemetry.registerCommand( + "databricks.unityCatalog.unpinSchema", + (node: UnityCatalogTreeNode) => { + if (node.kind === "schema") { + return unityCatalogTreeDataProvider.unpinSchema(node); + } + } ) ); diff --git a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.test.ts b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.test.ts index 84976413e..86d08f439 100644 --- a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.test.ts +++ b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.test.ts @@ -21,10 +21,12 @@ import { UnityCatalogTreeItem, UnityCatalogTreeNode, } from "./UnityCatalogTreeDataProvider"; +import {StateStorage} from "../../vscode-objs/StateStorage"; describe(__filename, () => { let disposables: Disposable[] = []; let mockConnectionManager: ConnectionManager; + let stubStateStorage: StateStorage; let mockWorkspaceClient: WorkspaceClient; let mockCatalogs: CatalogsService; let mockSchemas: SchemasService; @@ -36,6 +38,11 @@ describe(__filename, () => { beforeEach(() => { disposables = []; onDidChangeStateHandler = () => {}; + stubStateStorage = { + get: () => [] as string[], + set: async () => {}, + onDidChange: () => ({dispose() {}}), + } as unknown as StateStorage; mockCatalogs = mock(CatalogsService); when(mockCatalogs.list(anything(), anything())).thenCall(() => { @@ -134,7 +141,8 @@ describe(__filename, () => { it("returns undefined when not connected", async () => { when(mockConnectionManager.workspaceClient).thenReturn(undefined); const provider = new UnityCatalogTreeDataProvider( - instance(mockConnectionManager) + instance(mockConnectionManager), + stubStateStorage ); disposables.push(provider); @@ -144,7 +152,8 @@ describe(__filename, () => { it("lists catalogs sorted by name", async () => { const provider = new UnityCatalogTreeDataProvider( - instance(mockConnectionManager) + instance(mockConnectionManager), + stubStateStorage ); disposables.push(provider); @@ -166,7 +175,8 @@ describe(__filename, () => { it("lists schemas under a catalog", async () => { const provider = new UnityCatalogTreeDataProvider( - instance(mockConnectionManager) + instance(mockConnectionManager), + stubStateStorage ); disposables.push(provider); @@ -191,7 +201,8 @@ describe(__filename, () => { it("lists tables, volumes, and functions under a schema", async () => { const provider = new UnityCatalogTreeDataProvider( - instance(mockConnectionManager) + instance(mockConnectionManager), + stubStateStorage ); disposables.push(provider); @@ -226,7 +237,8 @@ describe(__filename, () => { it("fires onDidChangeTreeData when connection state changes", async () => { const provider = new UnityCatalogTreeDataProvider( - instance(mockConnectionManager) + instance(mockConnectionManager), + stubStateStorage ); disposables.push(provider); @@ -250,7 +262,7 @@ describe(__filename, () => { }, } as unknown as ConnectionManager; - const provider = new UnityCatalogTreeDataProvider(stubManager); + const provider = new UnityCatalogTreeDataProvider(stubManager, stubStateStorage); disposables.push(provider); const catalog: UnityCatalogTreeNode = { @@ -274,7 +286,8 @@ describe(__filename, () => { it("getTreeItem omits url when no host", async () => { const provider = new UnityCatalogTreeDataProvider( - instance(mockConnectionManager) + instance(mockConnectionManager), + stubStateStorage ); disposables.push(provider); @@ -291,7 +304,8 @@ describe(__filename, () => { it("getTreeItem for function node", async () => { const provider = new UnityCatalogTreeDataProvider( - instance(mockConnectionManager) + instance(mockConnectionManager), + stubStateStorage ); disposables.push(provider); @@ -314,7 +328,8 @@ describe(__filename, () => { it("table node carries enriched fields", async () => { const provider = new UnityCatalogTreeDataProvider( - instance(mockConnectionManager) + instance(mockConnectionManager), + stubStateStorage ); disposables.push(provider); @@ -341,7 +356,8 @@ describe(__filename, () => { it("getChildren for table with columns returns sorted column nodes", async () => { const provider = new UnityCatalogTreeDataProvider( - instance(mockConnectionManager) + instance(mockConnectionManager), + stubStateStorage ); disposables.push(provider); @@ -374,7 +390,8 @@ describe(__filename, () => { it("getChildren for table without columns returns undefined", async () => { const provider = new UnityCatalogTreeDataProvider( - instance(mockConnectionManager) + instance(mockConnectionManager), + stubStateStorage ); disposables.push(provider); @@ -394,7 +411,8 @@ describe(__filename, () => { it("getTreeItem for non-nullable column uses symbol-key icon", async () => { const provider = new UnityCatalogTreeDataProvider( - instance(mockConnectionManager) + instance(mockConnectionManager), + stubStateStorage ); disposables.push(provider); @@ -414,7 +432,8 @@ describe(__filename, () => { it("getTreeItem for nullable column uses symbol-field icon", async () => { const provider = new UnityCatalogTreeDataProvider( - instance(mockConnectionManager) + instance(mockConnectionManager), + stubStateStorage ); disposables.push(provider); @@ -432,7 +451,8 @@ describe(__filename, () => { it("getTreeItem for EXTERNAL table with storage has has-storage in contextValue", async () => { const provider = new UnityCatalogTreeDataProvider( - instance(mockConnectionManager) + instance(mockConnectionManager), + stubStateStorage ); disposables.push(provider); @@ -455,7 +475,8 @@ describe(__filename, () => { it("getTreeItem for VIEW table with view_definition has is-view in contextValue", async () => { const provider = new UnityCatalogTreeDataProvider( - instance(mockConnectionManager) + instance(mockConnectionManager), + stubStateStorage ); disposables.push(provider); @@ -478,7 +499,8 @@ describe(__filename, () => { it("volume node carries volumeType and shows EXTERNAL label suffix", async () => { const provider = new UnityCatalogTreeDataProvider( - instance(mockConnectionManager) + instance(mockConnectionManager), + stubStateStorage ); disposables.push(provider); @@ -501,7 +523,8 @@ describe(__filename, () => { it("catalog node carries comment", async () => { const provider = new UnityCatalogTreeDataProvider( - instance(mockConnectionManager) + instance(mockConnectionManager), + stubStateStorage ); disposables.push(provider); @@ -521,7 +544,8 @@ describe(__filename, () => { ); const provider = new UnityCatalogTreeDataProvider( - instance(mockConnectionManager) + instance(mockConnectionManager), + stubStateStorage ); disposables.push(provider); diff --git a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts index dbbdbfce1..4ad78591d 100644 --- a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts +++ b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts @@ -5,6 +5,7 @@ import {ConnectionManager} from "../../configuration/ConnectionManager"; import {Loggers} from "../../logger"; import {buildTreeItem} from "./nodeRenderer"; import {UnityCatalogTreeItem, UnityCatalogTreeNode} from "./types"; +import {StateStorage} from "../../vscode-objs/StateStorage"; export type { ColumnData, @@ -31,7 +32,10 @@ export class UnityCatalogTreeDataProvider readonly onDidChangeTreeData = this._onDidChangeTreeData.event; private readonly disposables: Disposable[] = []; - constructor(private readonly connectionManager: ConnectionManager) { + constructor( + private readonly connectionManager: ConnectionManager, + private readonly stateStorage: StateStorage + ) { this.disposables.push( this.connectionManager.onDidChangeState(() => { this._onDidChangeTreeData.fire(undefined); @@ -144,6 +148,11 @@ export class UnityCatalogTreeDataProvider const rows = await drainAsyncIterable( client.schemas.list({catalog_name: catalogName}) ); + const pinned = new Set( + this.stateStorage.get( + "databricks.unityCatalog.pinnedSchemas" + ) ?? [] + ); const result = rows .filter((s) => s.name) .map((s) => ({ @@ -152,8 +161,19 @@ export class UnityCatalogTreeDataProvider name: s.name!, fullName: s.full_name ?? `${catalogName}.${s.name}`, comment: s.comment, + pinned: pinned.has( + s.full_name ?? `${catalogName}.${s.name}` + ), })) - .sort((a, b) => a.name.localeCompare(b.name)); + .sort((a, b) => { + if (a.pinned && !b.pinned) { + return -1; + } + if (!a.pinned && b.pinned) { + return 1; + } + return a.name.localeCompare(b.name); + }); return result.length > 0 ? result : this.emptyNode("No schemas"); } catch (e) { return this.errorChildren(e, "schemas"); @@ -290,6 +310,34 @@ export class UnityCatalogTreeDataProvider return [{kind: "error", message}]; } + async pinSchema( + node: Extract + ): Promise { + const current = + this.stateStorage.get("databricks.unityCatalog.pinnedSchemas") ?? + []; + if (!current.includes(node.fullName)) { + await this.stateStorage.set( + "databricks.unityCatalog.pinnedSchemas", + [...current, node.fullName] + ); + } + this._onDidChangeTreeData.fire(undefined); + } + + async unpinSchema( + node: Extract + ): Promise { + const current = + this.stateStorage.get("databricks.unityCatalog.pinnedSchemas") ?? + []; + await this.stateStorage.set( + "databricks.unityCatalog.pinnedSchemas", + current.filter((n) => n !== node.fullName) + ); + this._onDidChangeTreeData.fire(undefined); + } + refresh(): void { this._onDidChangeTreeData.fire(undefined); } diff --git a/packages/databricks-vscode/src/ui/unity-catalog/nodeRenderer.ts b/packages/databricks-vscode/src/ui/unity-catalog/nodeRenderer.ts index d8edb9ab8..ca349a1d3 100644 --- a/packages/databricks-vscode/src/ui/unity-catalog/nodeRenderer.ts +++ b/packages/databricks-vscode/src/ui/unity-catalog/nodeRenderer.ts @@ -73,16 +73,20 @@ function renderSchema( if (node.comment) { tt.appendMarkdown(`\n\n${node.comment}`); } + const baseContextValue = exploreUrl + ? "unityCatalog.schema.has-url" + : "unityCatalog.schema"; return { label: node.name, + description: node.pinned ? "★" : undefined, tooltip: tt, iconPath: new ThemeIcon( "folder-library", new ThemeColor("databricks.unityCatalog.schema") ), - contextValue: exploreUrl - ? "unityCatalog.schema.has-url" - : "unityCatalog.schema", + contextValue: node.pinned + ? baseContextValue + ".is-pinned" + : baseContextValue, collapsibleState: TreeItemCollapsibleState.Collapsed, url: exploreUrl, copyText: node.fullName, diff --git a/packages/databricks-vscode/src/ui/unity-catalog/types.ts b/packages/databricks-vscode/src/ui/unity-catalog/types.ts index fd2f873a7..e0c0db561 100644 --- a/packages/databricks-vscode/src/ui/unity-catalog/types.ts +++ b/packages/databricks-vscode/src/ui/unity-catalog/types.ts @@ -17,6 +17,7 @@ export type UnityCatalogTreeNode = name: string; fullName: string; comment?: string; + pinned?: boolean; } | { kind: "table"; diff --git a/packages/databricks-vscode/src/vscode-objs/StateStorage.ts b/packages/databricks-vscode/src/vscode-objs/StateStorage.ts index 3c716599b..a32b72033 100644 --- a/packages/databricks-vscode/src/vscode-objs/StateStorage.ts +++ b/packages/databricks-vscode/src/vscode-objs/StateStorage.ts @@ -66,6 +66,11 @@ const StorageConfigurations = { location: "workspace", }), + "databricks.unityCatalog.pinnedSchemas": withType()({ + location: "workspace", + defaultValue: [], + }), + "databricks.lastInstalledExtensionVersion": withType()({ location: "global", defaultValue: "0.0.0", From 7945371a7dd26085a9fe709832716462d1f8e9a6 Mon Sep 17 00:00:00 2001 From: misha-db Date: Fri, 3 Apr 2026 13:24:38 +0400 Subject: [PATCH 12/17] Sort by ownership --- .../UnityCatalogTreeDataProvider.ts | 109 ++++++++++++------ .../src/ui/unity-catalog/nodeRenderer.ts | 11 +- .../src/ui/unity-catalog/types.ts | 4 +- 3 files changed, 84 insertions(+), 40 deletions(-) diff --git a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts index 4ad78591d..20bedcd1b 100644 --- a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts +++ b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import {ApiError, logging} from "@databricks/sdk-experimental"; +import {ApiError, logging, type iam} from "@databricks/sdk-experimental"; import {Disposable, EventEmitter, TreeDataProvider} from "vscode"; import {ConnectionManager} from "../../configuration/ConnectionManager"; import {Loggers} from "../../logger"; @@ -15,6 +15,20 @@ export type { const logger = logging.NamedLogger.getOrCreate(Loggers.Extension); +function isOwnedByUser( + owner: string | undefined, + user: iam.User | undefined +): boolean { + if (!owner || !user) { + return false; + } + if (owner === user.userName) { + return true; + } + console.log(`owner ${owner}`, JSON.stringify(user)); + return (user.groups ?? []).some((g) => g.display === owner); +} + async function drainAsyncIterable(iter: AsyncIterable): Promise { const out: T[] = []; for await (const item of iter) { @@ -52,7 +66,11 @@ export class UnityCatalogTreeDataProvider } getNodeExploreUrl(node: UnityCatalogTreeNode): string | undefined { - if (node.kind === "error" || node.kind === "column" || node.kind === "empty") { + if ( + node.kind === "error" || + node.kind === "column" || + node.kind === "empty" + ) { return undefined; } const fullNamePath = node.fullName.replaceAll(".", "/"); @@ -123,6 +141,8 @@ export class UnityCatalogTreeDataProvider ): Promise { try { const rows = await drainAsyncIterable(client.catalogs.list({})); + const currentUser = + this.connectionManager.databricksWorkspace?.user; const result = rows .filter((c) => c.name) .map((c) => ({ @@ -130,8 +150,18 @@ export class UnityCatalogTreeDataProvider name: c.name!, fullName: c.full_name ?? c.name!, comment: c.comment, + owner: c.owner, + owned: isOwnedByUser(c.owner, currentUser), })) - .sort((a, b) => a.name.localeCompare(b.name)); + .sort((a, b) => { + if (a.owned && !b.owned) { + return -1; + } + if (!a.owned && b.owned) { + return 1; + } + return a.name.localeCompare(b.name); + }); return result.length > 0 ? result : this.emptyNode("No catalogs found"); @@ -148,6 +178,8 @@ export class UnityCatalogTreeDataProvider const rows = await drainAsyncIterable( client.schemas.list({catalog_name: catalogName}) ); + const currentUser = + this.connectionManager.databricksWorkspace?.user; const pinned = new Set( this.stateStorage.get( "databricks.unityCatalog.pinnedSchemas" @@ -155,22 +187,25 @@ export class UnityCatalogTreeDataProvider ); const result = rows .filter((s) => s.name) - .map((s) => ({ - kind: "schema" as const, - catalogName, - name: s.name!, - fullName: s.full_name ?? `${catalogName}.${s.name}`, - comment: s.comment, - pinned: pinned.has( - s.full_name ?? `${catalogName}.${s.name}` - ), - })) + .map((s) => { + const fullName = s.full_name ?? `${catalogName}.${s.name}`; + return { + kind: "schema" as const, + catalogName, + name: s.name!, + fullName, + comment: s.comment, + owner: s.owner, + pinned: pinned.has(fullName), + owned: isOwnedByUser(s.owner, currentUser), + }; + }) .sort((a, b) => { - if (a.pinned && !b.pinned) { - return -1; - } - if (!a.pinned && b.pinned) { - return 1; + const rank = (n: typeof a) => + n.pinned ? 0 : n.owned ? 1 : 2; + const r = rank(a) - rank(b); + if (r !== 0) { + return r; } return a.name.localeCompare(b.name); }); @@ -268,27 +303,25 @@ export class UnityCatalogTreeDataProvider if (combined.length === 0) { return this.emptyNode("No items"); } - return combined.sort( - (a, b) => { - const an = - a.kind === "table" || - a.kind === "volume" || - a.kind === "function" - ? a.name - : ""; - const bn = - b.kind === "table" || - b.kind === "volume" || - b.kind === "function" - ? b.name - : ""; - const c = an.localeCompare(bn); - if (c !== 0) { - return c; - } - return (kindOrder[a.kind] ?? 0) - (kindOrder[b.kind] ?? 0); + return combined.sort((a, b) => { + const an = + a.kind === "table" || + a.kind === "volume" || + a.kind === "function" + ? a.name + : ""; + const bn = + b.kind === "table" || + b.kind === "volume" || + b.kind === "function" + ? b.name + : ""; + const c = an.localeCompare(bn); + if (c !== 0) { + return c; } - ); + return (kindOrder[a.kind] ?? 0) - (kindOrder[b.kind] ?? 0); + }); } catch (e) { return this.errorChildren(e, "tables, volumes, and functions"); } diff --git a/packages/databricks-vscode/src/ui/unity-catalog/nodeRenderer.ts b/packages/databricks-vscode/src/ui/unity-catalog/nodeRenderer.ts index ca349a1d3..5c07f77b4 100644 --- a/packages/databricks-vscode/src/ui/unity-catalog/nodeRenderer.ts +++ b/packages/databricks-vscode/src/ui/unity-catalog/nodeRenderer.ts @@ -51,6 +51,7 @@ function renderCatalog( } return { label: node.name, + description: node.owned ? "yours" : undefined, tooltip: tt, iconPath: new ThemeIcon( "library", @@ -76,9 +77,17 @@ function renderSchema( const baseContextValue = exploreUrl ? "unityCatalog.schema.has-url" : "unityCatalog.schema"; + let description: string | undefined; + if (node.pinned && node.owned) { + description = "★ · yours"; + } else if (node.pinned) { + description = "★"; + } else if (node.owned) { + description = "yours"; + } return { label: node.name, - description: node.pinned ? "★" : undefined, + description, tooltip: tt, iconPath: new ThemeIcon( "folder-library", diff --git a/packages/databricks-vscode/src/ui/unity-catalog/types.ts b/packages/databricks-vscode/src/ui/unity-catalog/types.ts index e0c0db561..c39849bfa 100644 --- a/packages/databricks-vscode/src/ui/unity-catalog/types.ts +++ b/packages/databricks-vscode/src/ui/unity-catalog/types.ts @@ -10,7 +10,7 @@ export interface ColumnData { } export type UnityCatalogTreeNode = - | {kind: "catalog"; name: string; fullName: string; comment?: string} + | {kind: "catalog"; name: string; fullName: string; comment?: string; owner?: string; owned?: boolean} | { kind: "schema"; catalogName: string; @@ -18,6 +18,8 @@ export type UnityCatalogTreeNode = fullName: string; comment?: string; pinned?: boolean; + owner?: string; + owned?: boolean; } | { kind: "table"; From f9db37330da505a720f692b006c4444cacaca2cb Mon Sep 17 00:00:00 2001 From: misha-db Date: Fri, 3 Apr 2026 13:38:09 +0400 Subject: [PATCH 13/17] Add TODO --- .../src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts index 20bedcd1b..5fb1c2fb2 100644 --- a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts +++ b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts @@ -25,8 +25,8 @@ function isOwnedByUser( if (owner === user.userName) { return true; } - console.log(`owner ${owner}`, JSON.stringify(user)); - return (user.groups ?? []).some((g) => g.display === owner); + // TODO: Check if user is owner through group? like: return (user.groups ?? []).some((g) => g.display === owner); + return false; } async function drainAsyncIterable(iter: AsyncIterable): Promise { From d6ca0080d1b0cedec22c3775f0524a2f9aa15b36 Mon Sep 17 00:00:00 2001 From: misha-db Date: Fri, 3 Apr 2026 18:47:58 +0400 Subject: [PATCH 14/17] Support registered models --- packages/databricks-vscode/package.json | 18 +++ .../UnityCatalogTreeDataProvider.ts | 145 ++++++++++++++---- .../src/ui/unity-catalog/nodeRenderer.ts | 88 +++++++++++ .../src/ui/unity-catalog/types.ts | 26 ++++ 4 files changed, 245 insertions(+), 32 deletions(-) diff --git a/packages/databricks-vscode/package.json b/packages/databricks-vscode/package.json index e3798bec9..ce979ccc7 100644 --- a/packages/databricks-vscode/package.json +++ b/packages/databricks-vscode/package.json @@ -581,6 +581,24 @@ "highContrast": "#8EAFC2", "highContrastLight": "#4A6B82" } + }, + { + "id": "databricks.unityCatalog.registeredModel", + "description": "Color for registered model nodes in the Unity Catalog view", + "defaults": { + "dark": "#C586C0", + "light": "#AF00DB", + "highContrast": "#C586C0" + } + }, + { + "id": "databricks.unityCatalog.modelVersion", + "description": "Color for model version nodes in the Unity Catalog view", + "defaults": { + "dark": "#B5CEA8", + "light": "#008000", + "highContrast": "#B5CEA8" + } } ], "viewsWelcome": [ diff --git a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts index 5fb1c2fb2..a81fb3ca4 100644 --- a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts +++ b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts @@ -74,10 +74,18 @@ export class UnityCatalogTreeDataProvider return undefined; } const fullNamePath = node.fullName.replaceAll(".", "/"); - const path = - node.kind === "function" - ? `functions/${fullNamePath}` - : fullNamePath; + let path = fullNamePath; + switch (node.kind) { + case "registeredModel": + path = `models/${fullNamePath}`; + break; + case "modelVersion": + path = `models/${fullNamePath}/version/${node.version}`; + break; + case "function": + path = `functions/${fullNamePath}`; + break; + } return this.getExploreUrl(path); } @@ -113,6 +121,10 @@ export class UnityCatalogTreeDataProvider ); } + if (element.kind === "registeredModel") { + return this.listModelVersions(client, element); + } + if (element.kind === "table") { if (!element.columns?.length) { return Promise.resolve(undefined); @@ -221,26 +233,33 @@ export class UnityCatalogTreeDataProvider schemaName: string ): Promise { try { - const [tableRows, volumeRows, functionRows] = await Promise.all([ - drainAsyncIterable( - client.tables.list({ - catalog_name: catalogName, - schema_name: schemaName, - }) - ), - drainAsyncIterable( - client.volumes.list({ - catalog_name: catalogName, - schema_name: schemaName, - }) - ), - drainAsyncIterable( - client.functions.list({ - catalog_name: catalogName, - schema_name: schemaName, - }) - ), - ]); + const [tableRows, volumeRows, functionRows, modelRows] = + await Promise.all([ + drainAsyncIterable( + client.tables.list({ + catalog_name: catalogName, + schema_name: schemaName, + }) + ), + drainAsyncIterable( + client.volumes.list({ + catalog_name: catalogName, + schema_name: schemaName, + }) + ), + drainAsyncIterable( + client.functions.list({ + catalog_name: catalogName, + schema_name: schemaName, + }) + ), + drainAsyncIterable( + client.registeredModels.list({ + catalog_name: catalogName, + schema_name: schemaName, + }) + ), + ]); const tableNodes: UnityCatalogTreeNode[] = tableRows .filter((t) => t.name) @@ -295,11 +314,38 @@ export class UnityCatalogTreeDataProvider fullName: `${catalogName}.${schemaName}.${f.name}`, })); - const kindOrder = {table: 0, volume: 1, function: 2} as Record< - string, - number - >; - const combined = [...tableNodes, ...volumeNodes, ...functionNodes]; + const modelNodes: UnityCatalogTreeNode[] = modelRows + .filter((m) => m.name) + .map((m) => ({ + kind: "registeredModel" as const, + catalogName, + schemaName, + name: m.name!, + fullName: + m.full_name ?? `${catalogName}.${schemaName}.${m.name}`, + comment: m.comment, + owner: m.owner, + storageLocation: m.storage_location, + aliases: m.aliases?.map((a) => ({ + alias_name: a.alias_name, + version_num: a.version_num, + })), + createdAt: m.created_at, + updatedAt: m.updated_at, + })); + + const kindOrder = { + table: 0, + volume: 1, + function: 2, + registeredModel: 3, + } as Record; + const combined = [ + ...tableNodes, + ...volumeNodes, + ...functionNodes, + ...modelNodes, + ]; if (combined.length === 0) { return this.emptyNode("No items"); } @@ -307,13 +353,15 @@ export class UnityCatalogTreeDataProvider const an = a.kind === "table" || a.kind === "volume" || - a.kind === "function" + a.kind === "function" || + a.kind === "registeredModel" ? a.name : ""; const bn = b.kind === "table" || b.kind === "volume" || - b.kind === "function" + b.kind === "function" || + b.kind === "registeredModel" ? b.name : ""; const c = an.localeCompare(bn); @@ -323,7 +371,40 @@ export class UnityCatalogTreeDataProvider return (kindOrder[a.kind] ?? 0) - (kindOrder[b.kind] ?? 0); }); } catch (e) { - return this.errorChildren(e, "tables, volumes, and functions"); + return this.errorChildren( + e, + "tables, volumes, functions, and registered models" + ); + } + } + + private async listModelVersions( + client: NonNullable, + model: Extract + ): Promise { + try { + const rows = await drainAsyncIterable( + client.modelVersions.list({full_name: model.fullName}) + ); + const nodes = rows + .filter((v) => v.version !== undefined) + .map((v) => ({ + kind: "modelVersion" as const, + catalogName: model.catalogName, + schemaName: model.schemaName, + modelName: model.name, + fullName: model.fullName, + version: v.version!, + comment: v.comment, + status: v.status, + storageLocation: v.storage_location, + createdAt: v.created_at, + createdBy: v.created_by, + })) + .sort((a, b) => b.version - a.version); + return nodes.length > 0 ? nodes : this.emptyNode("No versions"); + } catch (e) { + return this.errorChildren(e, "model versions"); } } diff --git a/packages/databricks-vscode/src/ui/unity-catalog/nodeRenderer.ts b/packages/databricks-vscode/src/ui/unity-catalog/nodeRenderer.ts index 5c07f77b4..2b1837460 100644 --- a/packages/databricks-vscode/src/ui/unity-catalog/nodeRenderer.ts +++ b/packages/databricks-vscode/src/ui/unity-catalog/nodeRenderer.ts @@ -227,6 +227,90 @@ function renderFunction( }; } +function renderRegisteredModel( + node: Extract, + exploreUrl: string | undefined +): UnityCatalogTreeItem { + const tt = new MarkdownString(`**${node.fullName}**`); + if (node.owner) { + tt.appendMarkdown(`\n\n*Owner:* ${node.owner}`); + } + if (node.comment) { + tt.appendMarkdown(`\n\n${node.comment}`); + } + if (node.aliases && node.aliases.length > 0) { + const aliasList = node.aliases + .filter((a) => a.alias_name) + .map((a) => + a.version_num !== undefined + ? `${a.alias_name} → v${a.version_num}` + : a.alias_name! + ) + .join(", "); + if (aliasList) { + tt.appendMarkdown(`\n\n*Aliases:* ${aliasList}`); + } + } + const cAt = formatTs(node.createdAt); + const uAt = formatTs(node.updatedAt); + if (cAt) { + tt.appendMarkdown(`\n\n*Created:* ${cAt}`); + } + if (uAt) { + tt.appendMarkdown(` *Updated:* ${uAt}`); + } + return { + label: node.name, + tooltip: tt, + iconPath: new ThemeIcon( + "beaker", + new ThemeColor("databricks.unityCatalog.registeredModel") + ), + contextValue: exploreUrl + ? "unityCatalog.registeredModel.has-url" + : "unityCatalog.registeredModel", + collapsibleState: TreeItemCollapsibleState.Collapsed, + url: exploreUrl, + copyText: node.fullName, + }; +} + +function renderModelVersion( + node: Extract, + exploreUrl: string | undefined +): UnityCatalogTreeItem { + const tt = new MarkdownString(`**v${node.version}**`); + if (node.status) { + tt.appendMarkdown(`\n\n*Status:* ${node.status}`); + } + if (node.comment) { + tt.appendMarkdown(`\n\n${node.comment}`); + } + if (node.createdBy) { + tt.appendMarkdown(`\n\n*Created by:* ${node.createdBy}`); + } + const cAt = formatTs(node.createdAt); + if (cAt) { + tt.appendMarkdown(`\n\n*Created:* ${cAt}`); + } + return { + label: `v${node.version}`, + description: + node.status && node.status !== "READY" ? node.status : undefined, + tooltip: tt, + iconPath: new ThemeIcon( + "tag", + new ThemeColor("databricks.unityCatalog.modelVersion") + ), + contextValue: exploreUrl + ? "unityCatalog.modelVersion.has-url" + : "unityCatalog.modelVersion", + collapsibleState: TreeItemCollapsibleState.None, + url: exploreUrl, + copyText: node.fullName, + }; +} + function renderColumn( node: Extract ): UnityCatalogTreeItem { @@ -278,6 +362,10 @@ export function buildTreeItem( return renderVolume(node, exploreUrl); case "function": return renderFunction(node, exploreUrl); + case "registeredModel": + return renderRegisteredModel(node, exploreUrl); + case "modelVersion": + return renderModelVersion(node, exploreUrl); case "column": return renderColumn(node); } diff --git a/packages/databricks-vscode/src/ui/unity-catalog/types.ts b/packages/databricks-vscode/src/ui/unity-catalog/types.ts index c39849bfa..0dd507a7b 100644 --- a/packages/databricks-vscode/src/ui/unity-catalog/types.ts +++ b/packages/databricks-vscode/src/ui/unity-catalog/types.ts @@ -56,6 +56,32 @@ export type UnityCatalogTreeNode = name: string; fullName: string; } + | { + kind: "registeredModel"; + catalogName: string; + schemaName: string; + name: string; + fullName: string; + comment?: string; + owner?: string; + storageLocation?: string; + aliases?: Array<{alias_name?: string; version_num?: number}>; + createdAt?: number; + updatedAt?: number; + } + | { + kind: "modelVersion"; + catalogName: string; + schemaName: string; + modelName: string; + fullName: string; + version: number; + comment?: string; + status?: string; + storageLocation?: string; + createdAt?: number; + createdBy?: string; + } | { kind: "column"; tableFullName: string; From a47de9000bc476764d4566713195dca7fc605da4 Mon Sep 17 00:00:00 2001 From: misha-db Date: Fri, 3 Apr 2026 19:12:58 +0400 Subject: [PATCH 15/17] Address PR comments --- .../UnityCatalogTreeDataProvider.ts | 318 ++++++++++-------- 1 file changed, 173 insertions(+), 145 deletions(-) diff --git a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts index a81fb3ca4..0541f84c5 100644 --- a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts +++ b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts @@ -93,12 +93,12 @@ export class UnityCatalogTreeDataProvider return buildTreeItem(element, this.getNodeExploreUrl(element)); } - getChildren( + async getChildren( element?: UnityCatalogTreeNode - ): Thenable { + ): Promise { const client = this.connectionManager.workspaceClient; if (!client) { - return Promise.resolve(undefined); + return undefined; } if (!element) { @@ -106,7 +106,7 @@ export class UnityCatalogTreeDataProvider } if (element.kind === "error") { - return Promise.resolve(undefined); + return undefined; } if (element.kind === "catalog") { @@ -127,25 +127,23 @@ export class UnityCatalogTreeDataProvider if (element.kind === "table") { if (!element.columns?.length) { - return Promise.resolve(undefined); + return undefined; } - return Promise.resolve( - [...element.columns] - .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)) - .map((col) => ({ - kind: "column" as const, - tableFullName: element.fullName, - name: col.name, - typeName: col.typeName, - typeText: col.typeText, - comment: col.comment, - nullable: col.nullable, - position: col.position, - })) - ); + return [...element.columns] + .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)) + .map((col) => ({ + kind: "column" as const, + tableFullName: element.fullName, + name: col.name, + typeName: col.typeName, + typeText: col.typeText, + comment: col.comment, + nullable: col.nullable, + position: col.position, + })); } - return Promise.resolve(undefined); + return undefined; } private async listCatalogs( @@ -232,124 +230,158 @@ export class UnityCatalogTreeDataProvider catalogName: string, schemaName: string ): Promise { - try { - const [tableRows, volumeRows, functionRows, modelRows] = - await Promise.all([ - drainAsyncIterable( - client.tables.list({ - catalog_name: catalogName, - schema_name: schemaName, - }) - ), - drainAsyncIterable( - client.volumes.list({ - catalog_name: catalogName, - schema_name: schemaName, - }) - ), - drainAsyncIterable( - client.functions.list({ - catalog_name: catalogName, - schema_name: schemaName, - }) - ), - drainAsyncIterable( - client.registeredModels.list({ - catalog_name: catalogName, - schema_name: schemaName, - }) - ), - ]); - - const tableNodes: UnityCatalogTreeNode[] = tableRows - .filter((t) => t.name) - .map((t) => ({ - kind: "table" as const, - catalogName, - schemaName, - name: t.name!, - fullName: - t.full_name ?? `${catalogName}.${schemaName}.${t.name}`, - tableType: t.table_type, - comment: t.comment, - dataSourceFormat: t.data_source_format, - storageLocation: t.storage_location, - viewDefinition: t.view_definition, - owner: t.owner, - createdBy: t.created_by, - createdAt: t.created_at, - updatedAt: t.updated_at, - columns: (t.columns ?? []).map((col) => ({ - name: col.name!, - typeName: col.type_name, - typeText: col.type_text, - comment: col.comment, - nullable: col.nullable, - position: col.position, - })), - })); - - const volumeNodes: UnityCatalogTreeNode[] = volumeRows - .filter((v) => v.name) - .map((v) => ({ - kind: "volume" as const, - catalogName, - schemaName, - name: v.name!, - fullName: - v.full_name ?? `${catalogName}.${schemaName}.${v.name}`, - volumeType: v.volume_type, - storageLocation: v.storage_location, - comment: v.comment, - owner: v.owner, - })); - - const functionNodes: UnityCatalogTreeNode[] = functionRows - .filter((f) => f.name) - .map((f) => ({ - kind: "function" as const, - catalogName, - schemaName, - name: f.name!, - fullName: `${catalogName}.${schemaName}.${f.name}`, - })); - - const modelNodes: UnityCatalogTreeNode[] = modelRows - .filter((m) => m.name) - .map((m) => ({ - kind: "registeredModel" as const, - catalogName, - schemaName, - name: m.name!, - fullName: - m.full_name ?? `${catalogName}.${schemaName}.${m.name}`, - comment: m.comment, - owner: m.owner, - storageLocation: m.storage_location, - aliases: m.aliases?.map((a) => ({ - alias_name: a.alias_name, - version_num: a.version_num, - })), - createdAt: m.created_at, - updatedAt: m.updated_at, - })); - - const kindOrder = { - table: 0, - volume: 1, - function: 2, - registeredModel: 3, - } as Record; - const combined = [ - ...tableNodes, - ...volumeNodes, - ...functionNodes, - ...modelNodes, - ]; - if (combined.length === 0) { - return this.emptyNode("No items"); - } - return combined.sort((a, b) => { + const [tablesResult, volumesResult, functionsResult, modelsResult] = + await Promise.allSettled([ + drainAsyncIterable( + client.tables.list({ + catalog_name: catalogName, + schema_name: schemaName, + }) + ), + drainAsyncIterable( + client.volumes.list({ + catalog_name: catalogName, + schema_name: schemaName, + }) + ), + drainAsyncIterable( + client.functions.list({ + catalog_name: catalogName, + schema_name: schemaName, + }) + ), + drainAsyncIterable( + client.registeredModels.list({ + catalog_name: catalogName, + schema_name: schemaName, + }) + ), + ]); + + const tableNodes: UnityCatalogTreeNode[] = + tablesResult.status === "fulfilled" + ? tablesResult.value + .filter((t) => t.name) + .map((t) => ({ + kind: "table" as const, + catalogName, + schemaName, + name: t.name!, + fullName: + t.full_name ?? + `${catalogName}.${schemaName}.${t.name}`, + tableType: t.table_type, + comment: t.comment, + dataSourceFormat: t.data_source_format, + storageLocation: t.storage_location, + viewDefinition: t.view_definition, + owner: t.owner, + createdBy: t.created_by, + createdAt: t.created_at, + updatedAt: t.updated_at, + columns: (t.columns ?? []).map((col) => ({ + name: col.name!, + typeName: col.type_name, + typeText: col.type_text, + comment: col.comment, + nullable: col.nullable, + position: col.position, + })), + })) + : []; + + const volumeNodes: UnityCatalogTreeNode[] = + volumesResult.status === "fulfilled" + ? volumesResult.value + .filter((v) => v.name) + .map((v) => ({ + kind: "volume" as const, + catalogName, + schemaName, + name: v.name!, + fullName: + v.full_name ?? + `${catalogName}.${schemaName}.${v.name}`, + volumeType: v.volume_type, + storageLocation: v.storage_location, + comment: v.comment, + owner: v.owner, + })) + : []; + + const functionNodes: UnityCatalogTreeNode[] = + functionsResult.status === "fulfilled" + ? functionsResult.value + .filter((f) => f.name) + .map((f) => ({ + kind: "function" as const, + catalogName, + schemaName, + name: f.name!, + fullName: `${catalogName}.${schemaName}.${f.name}`, + })) + : []; + + const modelNodes: UnityCatalogTreeNode[] = + modelsResult.status === "fulfilled" + ? modelsResult.value + .filter((m) => m.name) + .map((m) => ({ + kind: "registeredModel" as const, + catalogName, + schemaName, + name: m.name!, + fullName: + m.full_name ?? + `${catalogName}.${schemaName}.${m.name}`, + comment: m.comment, + owner: m.owner, + storageLocation: m.storage_location, + aliases: m.aliases?.map((a) => ({ + alias_name: a.alias_name, + version_num: a.version_num, + })), + createdAt: m.created_at, + updatedAt: m.updated_at, + })) + : []; + + const errorNodes: UnityCatalogTreeNode[] = [ + ...(tablesResult.status === "rejected" + ? (this.errorChildren(tablesResult.reason, "tables") ?? []) + : []), + ...(volumesResult.status === "rejected" + ? (this.errorChildren(volumesResult.reason, "volumes") ?? []) + : []), + ...(functionsResult.status === "rejected" + ? (this.errorChildren(functionsResult.reason, "functions") ?? + []) + : []), + ...(modelsResult.status === "rejected" + ? (this.errorChildren( + modelsResult.reason, + "registered models" + ) ?? []) + : []), + ]; + + const kindOrder = { + table: 0, + volume: 1, + function: 2, + registeredModel: 3, + } as Record; + const contentNodes = [ + ...tableNodes, + ...volumeNodes, + ...functionNodes, + ...modelNodes, + ]; + if (contentNodes.length === 0 && errorNodes.length === 0) { + return this.emptyNode("No items"); + } + return [ + ...contentNodes.sort((a, b) => { const an = a.kind === "table" || a.kind === "volume" || @@ -369,13 +401,9 @@ export class UnityCatalogTreeDataProvider return c; } return (kindOrder[a.kind] ?? 0) - (kindOrder[b.kind] ?? 0); - }); - } catch (e) { - return this.errorChildren( - e, - "tables, volumes, functions, and registered models" - ); - } + }), + ...errorNodes, + ]; } private async listModelVersions( From 5d2ff6a038726407601b753a5ae0e5e97a7ce32e Mon Sep 17 00:00:00 2001 From: misha-db Date: Fri, 3 Apr 2026 19:41:41 +0400 Subject: [PATCH 16/17] Move logic out of tree provider --- .../UnityCatalogTreeDataProvider.test.ts | 19 +- .../UnityCatalogTreeDataProvider.ts | 354 +----------------- .../src/ui/unity-catalog/loaders.ts | 300 +++++++++++++++ .../src/ui/unity-catalog/nodeRenderer.ts | 60 ++- .../src/ui/unity-catalog/utils.ts | 34 ++ 5 files changed, 395 insertions(+), 372 deletions(-) create mode 100644 packages/databricks-vscode/src/ui/unity-catalog/loaders.ts create mode 100644 packages/databricks-vscode/src/ui/unity-catalog/utils.ts diff --git a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.test.ts b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.test.ts index 86d08f439..6fe00f38d 100644 --- a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.test.ts +++ b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.test.ts @@ -7,6 +7,7 @@ import {WorkspaceClient} from "@databricks/sdk-experimental"; import { CatalogsService, FunctionsService, + RegisteredModelsService, SchemasService, TablesService, VolumesService, @@ -33,6 +34,7 @@ describe(__filename, () => { let mockTables: TablesService; let mockVolumes: VolumesService; let mockFunctions: FunctionsService; + let mockRegisteredModels: RegisteredModelsService; let onDidChangeStateHandler: (s: ConnectionState) => void; beforeEach(() => { @@ -115,12 +117,23 @@ describe(__filename, () => { return impl(); }); + mockRegisteredModels = mock(RegisteredModelsService); + when(mockRegisteredModels.list(anything(), anything())).thenCall(() => { + async function* impl() { + /* empty */ + } + return impl(); + }); + mockWorkspaceClient = mock(WorkspaceClient); when(mockWorkspaceClient.catalogs).thenReturn(instance(mockCatalogs)); when(mockWorkspaceClient.schemas).thenReturn(instance(mockSchemas)); when(mockWorkspaceClient.tables).thenReturn(instance(mockTables)); when(mockWorkspaceClient.volumes).thenReturn(instance(mockVolumes)); when(mockWorkspaceClient.functions).thenReturn(instance(mockFunctions)); + when(mockWorkspaceClient.registeredModels).thenReturn( + instance(mockRegisteredModels) + ); mockConnectionManager = mock(ConnectionManager); when(mockConnectionManager.workspaceClient).thenReturn( @@ -560,7 +573,9 @@ describe(__filename, () => { )) as UnityCatalogTreeNode[]; assert(children); - assert.strictEqual(children.length, 1); - assert.strictEqual(children[0].kind, "error"); + // allSettled: tables (t1) and volumes (v1) still succeed; only functions errors + assert.strictEqual(children.length, 3); + assert.notStrictEqual(children[0].kind, "error"); + assert.strictEqual(children[children.length - 1].kind, "error"); }); }); diff --git a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts index 0541f84c5..7ae7ec6e7 100644 --- a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts +++ b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts @@ -1,11 +1,15 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import {ApiError, logging, type iam} from "@databricks/sdk-experimental"; import {Disposable, EventEmitter, TreeDataProvider} from "vscode"; import {ConnectionManager} from "../../configuration/ConnectionManager"; -import {Loggers} from "../../logger"; import {buildTreeItem} from "./nodeRenderer"; import {UnityCatalogTreeItem, UnityCatalogTreeNode} from "./types"; import {StateStorage} from "../../vscode-objs/StateStorage"; +import { + loadCatalogs, + loadSchemas, + loadSchemaChildren, + loadModelVersions, +} from "./loaders"; export type { ColumnData, @@ -13,30 +17,6 @@ export type { UnityCatalogTreeNode, } from "./types"; -const logger = logging.NamedLogger.getOrCreate(Loggers.Extension); - -function isOwnedByUser( - owner: string | undefined, - user: iam.User | undefined -): boolean { - if (!owner || !user) { - return false; - } - if (owner === user.userName) { - return true; - } - // TODO: Check if user is owner through group? like: return (user.groups ?? []).some((g) => g.display === owner); - return false; -} - -async function drainAsyncIterable(iter: AsyncIterable): Promise { - const out: T[] = []; - for await (const item of iter) { - out.push(item); - } - return out; -} - export class UnityCatalogTreeDataProvider implements TreeDataProvider, Disposable { @@ -101,8 +81,11 @@ export class UnityCatalogTreeDataProvider return undefined; } + const currentUser = + this.connectionManager.databricksWorkspace?.user; + if (!element) { - return this.listCatalogs(client); + return loadCatalogs(client, currentUser); } if (element.kind === "error") { @@ -110,11 +93,16 @@ export class UnityCatalogTreeDataProvider } if (element.kind === "catalog") { - return this.listSchemas(client, element.name); + const pinned = new Set( + this.stateStorage.get( + "databricks.unityCatalog.pinnedSchemas" + ) ?? [] + ); + return loadSchemas(client, element.name, currentUser, pinned); } if (element.kind === "schema") { - return this.listSchemaChildren( + return loadSchemaChildren( client, element.catalogName, element.name @@ -122,7 +110,7 @@ export class UnityCatalogTreeDataProvider } if (element.kind === "registeredModel") { - return this.listModelVersions(client, element); + return loadModelVersions(client, element); } if (element.kind === "table") { @@ -146,312 +134,6 @@ export class UnityCatalogTreeDataProvider return undefined; } - private async listCatalogs( - client: NonNullable - ): Promise { - try { - const rows = await drainAsyncIterable(client.catalogs.list({})); - const currentUser = - this.connectionManager.databricksWorkspace?.user; - const result = rows - .filter((c) => c.name) - .map((c) => ({ - kind: "catalog" as const, - name: c.name!, - fullName: c.full_name ?? c.name!, - comment: c.comment, - owner: c.owner, - owned: isOwnedByUser(c.owner, currentUser), - })) - .sort((a, b) => { - if (a.owned && !b.owned) { - return -1; - } - if (!a.owned && b.owned) { - return 1; - } - return a.name.localeCompare(b.name); - }); - return result.length > 0 - ? result - : this.emptyNode("No catalogs found"); - } catch (e) { - return this.errorChildren(e, "catalogs"); - } - } - - private async listSchemas( - client: NonNullable, - catalogName: string - ): Promise { - try { - const rows = await drainAsyncIterable( - client.schemas.list({catalog_name: catalogName}) - ); - const currentUser = - this.connectionManager.databricksWorkspace?.user; - const pinned = new Set( - this.stateStorage.get( - "databricks.unityCatalog.pinnedSchemas" - ) ?? [] - ); - const result = rows - .filter((s) => s.name) - .map((s) => { - const fullName = s.full_name ?? `${catalogName}.${s.name}`; - return { - kind: "schema" as const, - catalogName, - name: s.name!, - fullName, - comment: s.comment, - owner: s.owner, - pinned: pinned.has(fullName), - owned: isOwnedByUser(s.owner, currentUser), - }; - }) - .sort((a, b) => { - const rank = (n: typeof a) => - n.pinned ? 0 : n.owned ? 1 : 2; - const r = rank(a) - rank(b); - if (r !== 0) { - return r; - } - return a.name.localeCompare(b.name); - }); - return result.length > 0 ? result : this.emptyNode("No schemas"); - } catch (e) { - return this.errorChildren(e, "schemas"); - } - } - - private async listSchemaChildren( - client: NonNullable, - catalogName: string, - schemaName: string - ): Promise { - const [tablesResult, volumesResult, functionsResult, modelsResult] = - await Promise.allSettled([ - drainAsyncIterable( - client.tables.list({ - catalog_name: catalogName, - schema_name: schemaName, - }) - ), - drainAsyncIterable( - client.volumes.list({ - catalog_name: catalogName, - schema_name: schemaName, - }) - ), - drainAsyncIterable( - client.functions.list({ - catalog_name: catalogName, - schema_name: schemaName, - }) - ), - drainAsyncIterable( - client.registeredModels.list({ - catalog_name: catalogName, - schema_name: schemaName, - }) - ), - ]); - - const tableNodes: UnityCatalogTreeNode[] = - tablesResult.status === "fulfilled" - ? tablesResult.value - .filter((t) => t.name) - .map((t) => ({ - kind: "table" as const, - catalogName, - schemaName, - name: t.name!, - fullName: - t.full_name ?? - `${catalogName}.${schemaName}.${t.name}`, - tableType: t.table_type, - comment: t.comment, - dataSourceFormat: t.data_source_format, - storageLocation: t.storage_location, - viewDefinition: t.view_definition, - owner: t.owner, - createdBy: t.created_by, - createdAt: t.created_at, - updatedAt: t.updated_at, - columns: (t.columns ?? []).map((col) => ({ - name: col.name!, - typeName: col.type_name, - typeText: col.type_text, - comment: col.comment, - nullable: col.nullable, - position: col.position, - })), - })) - : []; - - const volumeNodes: UnityCatalogTreeNode[] = - volumesResult.status === "fulfilled" - ? volumesResult.value - .filter((v) => v.name) - .map((v) => ({ - kind: "volume" as const, - catalogName, - schemaName, - name: v.name!, - fullName: - v.full_name ?? - `${catalogName}.${schemaName}.${v.name}`, - volumeType: v.volume_type, - storageLocation: v.storage_location, - comment: v.comment, - owner: v.owner, - })) - : []; - - const functionNodes: UnityCatalogTreeNode[] = - functionsResult.status === "fulfilled" - ? functionsResult.value - .filter((f) => f.name) - .map((f) => ({ - kind: "function" as const, - catalogName, - schemaName, - name: f.name!, - fullName: `${catalogName}.${schemaName}.${f.name}`, - })) - : []; - - const modelNodes: UnityCatalogTreeNode[] = - modelsResult.status === "fulfilled" - ? modelsResult.value - .filter((m) => m.name) - .map((m) => ({ - kind: "registeredModel" as const, - catalogName, - schemaName, - name: m.name!, - fullName: - m.full_name ?? - `${catalogName}.${schemaName}.${m.name}`, - comment: m.comment, - owner: m.owner, - storageLocation: m.storage_location, - aliases: m.aliases?.map((a) => ({ - alias_name: a.alias_name, - version_num: a.version_num, - })), - createdAt: m.created_at, - updatedAt: m.updated_at, - })) - : []; - - const errorNodes: UnityCatalogTreeNode[] = [ - ...(tablesResult.status === "rejected" - ? (this.errorChildren(tablesResult.reason, "tables") ?? []) - : []), - ...(volumesResult.status === "rejected" - ? (this.errorChildren(volumesResult.reason, "volumes") ?? []) - : []), - ...(functionsResult.status === "rejected" - ? (this.errorChildren(functionsResult.reason, "functions") ?? - []) - : []), - ...(modelsResult.status === "rejected" - ? (this.errorChildren( - modelsResult.reason, - "registered models" - ) ?? []) - : []), - ]; - - const kindOrder = { - table: 0, - volume: 1, - function: 2, - registeredModel: 3, - } as Record; - const contentNodes = [ - ...tableNodes, - ...volumeNodes, - ...functionNodes, - ...modelNodes, - ]; - if (contentNodes.length === 0 && errorNodes.length === 0) { - return this.emptyNode("No items"); - } - return [ - ...contentNodes.sort((a, b) => { - const an = - a.kind === "table" || - a.kind === "volume" || - a.kind === "function" || - a.kind === "registeredModel" - ? a.name - : ""; - const bn = - b.kind === "table" || - b.kind === "volume" || - b.kind === "function" || - b.kind === "registeredModel" - ? b.name - : ""; - const c = an.localeCompare(bn); - if (c !== 0) { - return c; - } - return (kindOrder[a.kind] ?? 0) - (kindOrder[b.kind] ?? 0); - }), - ...errorNodes, - ]; - } - - private async listModelVersions( - client: NonNullable, - model: Extract - ): Promise { - try { - const rows = await drainAsyncIterable( - client.modelVersions.list({full_name: model.fullName}) - ); - const nodes = rows - .filter((v) => v.version !== undefined) - .map((v) => ({ - kind: "modelVersion" as const, - catalogName: model.catalogName, - schemaName: model.schemaName, - modelName: model.name, - fullName: model.fullName, - version: v.version!, - comment: v.comment, - status: v.status, - storageLocation: v.storage_location, - createdAt: v.created_at, - createdBy: v.created_by, - })) - .sort((a, b) => b.version - a.version); - return nodes.length > 0 ? nodes : this.emptyNode("No versions"); - } catch (e) { - return this.errorChildren(e, "model versions"); - } - } - - private emptyNode(message: string): UnityCatalogTreeNode[] { - return [{kind: "empty", message}]; - } - - private errorChildren( - e: unknown, - resource: string - ): UnityCatalogTreeNode[] | undefined { - const message = - e instanceof ApiError - ? `Failed to load ${resource}: ${e.message}` - : `Failed to load ${resource}`; - logger.error(`Unity Catalog: ${message}`, e); - return [{kind: "error", message}]; - } - async pinSchema( node: Extract ): Promise { diff --git a/packages/databricks-vscode/src/ui/unity-catalog/loaders.ts b/packages/databricks-vscode/src/ui/unity-catalog/loaders.ts new file mode 100644 index 000000000..b1bb217ef --- /dev/null +++ b/packages/databricks-vscode/src/ui/unity-catalog/loaders.ts @@ -0,0 +1,300 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import {ApiError, logging, type iam} from "@databricks/sdk-experimental"; +import {ConnectionManager} from "../../configuration/ConnectionManager"; +import {Loggers} from "../../logger"; +import {UnityCatalogTreeNode} from "./types"; +import {drainAsyncIterable, isOwnedByUser} from "./utils"; + +const logger = logging.NamedLogger.getOrCreate(Loggers.Extension); + +type Client = NonNullable; + +function emptyNode(message: string): UnityCatalogTreeNode[] { + return [{kind: "empty", message}]; +} + +function errorNode(e: unknown, resource: string): UnityCatalogTreeNode[] { + const message = + e instanceof ApiError + ? `Failed to load ${resource}: ${e.message}` + : `Failed to load ${resource}`; + logger.error(`Unity Catalog: ${message}`, e); + return [{kind: "error", message}]; +} + +export async function loadCatalogs( + client: Client, + currentUser: iam.User | undefined +): Promise { + try { + const rows = await drainAsyncIterable(client.catalogs.list({})); + const result = rows + .filter((c) => c.name) + .map((c) => ({ + kind: "catalog" as const, + name: c.name!, + fullName: c.full_name ?? c.name!, + comment: c.comment, + owner: c.owner, + owned: isOwnedByUser(c.owner, currentUser), + })) + .sort((a, b) => { + if (a.owned && !b.owned) { + return -1; + } + if (!a.owned && b.owned) { + return 1; + } + return a.name.localeCompare(b.name); + }); + return result.length > 0 ? result : emptyNode("No catalogs found"); + } catch (e) { + return errorNode(e, "catalogs"); + } +} + +export async function loadSchemas( + client: Client, + catalogName: string, + currentUser: iam.User | undefined, + pinnedSchemas: Set +): Promise { + try { + const rows = await drainAsyncIterable( + client.schemas.list({catalog_name: catalogName}) + ); + const result = rows + .filter((s) => s.name) + .map((s) => { + const fullName = s.full_name ?? `${catalogName}.${s.name}`; + return { + kind: "schema" as const, + catalogName, + name: s.name!, + fullName, + comment: s.comment, + owner: s.owner, + pinned: pinnedSchemas.has(fullName), + owned: isOwnedByUser(s.owner, currentUser), + }; + }) + .sort((a, b) => { + const rank = (n: typeof a) => (n.pinned ? 0 : n.owned ? 1 : 2); + const r = rank(a) - rank(b); + if (r !== 0) { + return r; + } + return a.name.localeCompare(b.name); + }); + return result.length > 0 ? result : emptyNode("No schemas"); + } catch (e) { + return errorNode(e, "schemas"); + } +} + +export async function loadSchemaChildren( + client: Client, + catalogName: string, + schemaName: string +): Promise { + const [tablesResult, volumesResult, functionsResult, modelsResult] = + await Promise.allSettled([ + drainAsyncIterable( + client.tables.list({ + catalog_name: catalogName, + schema_name: schemaName, + }) + ), + drainAsyncIterable( + client.volumes.list({ + catalog_name: catalogName, + schema_name: schemaName, + }) + ), + drainAsyncIterable( + client.functions.list({ + catalog_name: catalogName, + schema_name: schemaName, + }) + ), + drainAsyncIterable( + client.registeredModels.list({ + catalog_name: catalogName, + schema_name: schemaName, + }) + ), + ]); + + const tableNodes: UnityCatalogTreeNode[] = + tablesResult.status === "fulfilled" + ? tablesResult.value + .filter((t) => t.name) + .map((t) => ({ + kind: "table" as const, + catalogName, + schemaName, + name: t.name!, + fullName: + t.full_name ?? + `${catalogName}.${schemaName}.${t.name}`, + tableType: t.table_type, + comment: t.comment, + dataSourceFormat: t.data_source_format, + storageLocation: t.storage_location, + viewDefinition: t.view_definition, + owner: t.owner, + createdBy: t.created_by, + createdAt: t.created_at, + updatedAt: t.updated_at, + columns: (t.columns ?? []).map((col) => ({ + name: col.name!, + typeName: col.type_name, + typeText: col.type_text, + comment: col.comment, + nullable: col.nullable, + position: col.position, + })), + })) + : []; + + const volumeNodes: UnityCatalogTreeNode[] = + volumesResult.status === "fulfilled" + ? volumesResult.value + .filter((v) => v.name) + .map((v) => ({ + kind: "volume" as const, + catalogName, + schemaName, + name: v.name!, + fullName: + v.full_name ?? + `${catalogName}.${schemaName}.${v.name}`, + volumeType: v.volume_type, + storageLocation: v.storage_location, + comment: v.comment, + owner: v.owner, + })) + : []; + + const functionNodes: UnityCatalogTreeNode[] = + functionsResult.status === "fulfilled" + ? functionsResult.value + .filter((f) => f.name) + .map((f) => ({ + kind: "function" as const, + catalogName, + schemaName, + name: f.name!, + fullName: `${catalogName}.${schemaName}.${f.name}`, + })) + : []; + + const modelNodes: UnityCatalogTreeNode[] = + modelsResult.status === "fulfilled" + ? modelsResult.value + .filter((m) => m.name) + .map((m) => ({ + kind: "registeredModel" as const, + catalogName, + schemaName, + name: m.name!, + fullName: + m.full_name ?? + `${catalogName}.${schemaName}.${m.name}`, + comment: m.comment, + owner: m.owner, + storageLocation: m.storage_location, + aliases: m.aliases?.map((a) => ({ + alias_name: a.alias_name, + version_num: a.version_num, + })), + createdAt: m.created_at, + updatedAt: m.updated_at, + })) + : []; + + const errNodes: UnityCatalogTreeNode[] = [ + ...(tablesResult.status === "rejected" + ? errorNode(tablesResult.reason, "tables") + : []), + ...(volumesResult.status === "rejected" + ? errorNode(volumesResult.reason, "volumes") + : []), + ...(functionsResult.status === "rejected" + ? errorNode(functionsResult.reason, "functions") + : []), + ...(modelsResult.status === "rejected" + ? errorNode(modelsResult.reason, "registered models") + : []), + ]; + + const kindOrder = { + table: 0, + volume: 1, + function: 2, + registeredModel: 3, + } as Record; + const contentNodes = [ + ...tableNodes, + ...volumeNodes, + ...functionNodes, + ...modelNodes, + ]; + if (contentNodes.length === 0 && errNodes.length === 0) { + return emptyNode("No items"); + } + return [ + ...contentNodes.sort((a, b) => { + const an = + a.kind === "table" || + a.kind === "volume" || + a.kind === "function" || + a.kind === "registeredModel" + ? a.name + : ""; + const bn = + b.kind === "table" || + b.kind === "volume" || + b.kind === "function" || + b.kind === "registeredModel" + ? b.name + : ""; + const c = an.localeCompare(bn); + if (c !== 0) { + return c; + } + return (kindOrder[a.kind] ?? 0) - (kindOrder[b.kind] ?? 0); + }), + ...errNodes, + ]; +} + +export async function loadModelVersions( + client: Client, + model: Extract +): Promise { + try { + const rows = await drainAsyncIterable( + client.modelVersions.list({full_name: model.fullName}) + ); + const nodes = rows + .filter((v) => v.version !== undefined) + .map((v) => ({ + kind: "modelVersion" as const, + catalogName: model.catalogName, + schemaName: model.schemaName, + modelName: model.name, + fullName: model.fullName, + version: v.version!, + comment: v.comment, + status: v.status, + storageLocation: v.storage_location, + createdAt: v.created_at, + createdBy: v.created_by, + })) + .sort((a, b) => b.version - a.version); + return nodes.length > 0 ? nodes : emptyNode("No versions"); + } catch (e) { + return errorNode(e, "model versions"); + } +} diff --git a/packages/databricks-vscode/src/ui/unity-catalog/nodeRenderer.ts b/packages/databricks-vscode/src/ui/unity-catalog/nodeRenderer.ts index 2b1837460..6d86f558a 100644 --- a/packages/databricks-vscode/src/ui/unity-catalog/nodeRenderer.ts +++ b/packages/databricks-vscode/src/ui/unity-catalog/nodeRenderer.ts @@ -5,14 +5,34 @@ import { TreeItemCollapsibleState, } from "vscode"; import {UnityCatalogTreeNode, UnityCatalogTreeItem} from "./types"; +import {formatTs} from "./utils"; -function formatTs(ms: number | undefined): string | undefined { - if (ms === undefined) { - return undefined; +export function buildTreeItem( + node: UnityCatalogTreeNode, + exploreUrl: string | undefined +): UnityCatalogTreeItem { + switch (node.kind) { + case "error": + return renderError(node); + case "empty": + return renderEmpty(node); + case "catalog": + return renderCatalog(node, exploreUrl); + case "schema": + return renderSchema(node, exploreUrl); + case "table": + return renderTable(node, exploreUrl); + case "volume": + return renderVolume(node, exploreUrl); + case "function": + return renderFunction(node, exploreUrl); + case "registeredModel": + return renderRegisteredModel(node, exploreUrl); + case "modelVersion": + return renderModelVersion(node, exploreUrl); + case "column": + return renderColumn(node); } - return ( - new Date(ms).toISOString().replace("T", " ").substring(0, 19) + " UTC" - ); } function renderError( @@ -342,31 +362,3 @@ function renderColumn( copyText: node.name, }; } - -export function buildTreeItem( - node: UnityCatalogTreeNode, - exploreUrl: string | undefined -): UnityCatalogTreeItem { - switch (node.kind) { - case "error": - return renderError(node); - case "empty": - return renderEmpty(node); - case "catalog": - return renderCatalog(node, exploreUrl); - case "schema": - return renderSchema(node, exploreUrl); - case "table": - return renderTable(node, exploreUrl); - case "volume": - return renderVolume(node, exploreUrl); - case "function": - return renderFunction(node, exploreUrl); - case "registeredModel": - return renderRegisteredModel(node, exploreUrl); - case "modelVersion": - return renderModelVersion(node, exploreUrl); - case "column": - return renderColumn(node); - } -} diff --git a/packages/databricks-vscode/src/ui/unity-catalog/utils.ts b/packages/databricks-vscode/src/ui/unity-catalog/utils.ts new file mode 100644 index 000000000..9cdc8160b --- /dev/null +++ b/packages/databricks-vscode/src/ui/unity-catalog/utils.ts @@ -0,0 +1,34 @@ +import {type iam} from "@databricks/sdk-experimental"; + +export async function drainAsyncIterable( + iter: AsyncIterable +): Promise { + const out: T[] = []; + for await (const item of iter) { + out.push(item); + } + return out; +} + +export function isOwnedByUser( + owner: string | undefined, + user: iam.User | undefined +): boolean { + if (!owner || !user) { + return false; + } + if (owner === user.userName) { + return true; + } + // TODO: Check if user is owner through group? like: return (user.groups ?? []).some((g) => g.display === owner); + return false; +} + +export function formatTs(ms: number | undefined): string | undefined { + if (ms === undefined) { + return undefined; + } + return ( + new Date(ms).toISOString().replace("T", " ").substring(0, 19) + " UTC" + ); +} From c95375d6f4e7e392a39dd98e7f853447c29d3a2d Mon Sep 17 00:00:00 2001 From: misha-db Date: Sat, 4 Apr 2026 01:32:03 +0400 Subject: [PATCH 17/17] Fix tests --- .../UnityCatalogTreeDataProvider.test.ts | 187 +++++++++++++++++- 1 file changed, 178 insertions(+), 9 deletions(-) diff --git a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.test.ts b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.test.ts index 6fe00f38d..3ffc16a18 100644 --- a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.test.ts +++ b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.test.ts @@ -7,6 +7,7 @@ import {WorkspaceClient} from "@databricks/sdk-experimental"; import { CatalogsService, FunctionsService, + ModelVersionsService, RegisteredModelsService, SchemasService, TablesService, @@ -35,6 +36,7 @@ describe(__filename, () => { let mockVolumes: VolumesService; let mockFunctions: FunctionsService; let mockRegisteredModels: RegisteredModelsService; + let mockModelVersions: ModelVersionsService; let onDidChangeStateHandler: (s: ConnectionState) => void; beforeEach(() => { @@ -47,7 +49,7 @@ describe(__filename, () => { } as unknown as StateStorage; mockCatalogs = mock(CatalogsService); - when(mockCatalogs.list(anything(), anything())).thenCall(() => { + when(mockCatalogs.list(anything())).thenCall(() => { async function* impl() { yield {name: "c_b", full_name: "c_b"}; yield {name: "c_a", full_name: "c_a"}; @@ -56,7 +58,7 @@ describe(__filename, () => { }); mockSchemas = mock(SchemasService); - when(mockSchemas.list(anything(), anything())).thenCall(() => { + when(mockSchemas.list(anything())).thenCall(() => { async function* impl() { yield {name: "s_b", full_name: "cat.s_b"}; yield {name: "s_a", full_name: "cat.s_a"}; @@ -65,7 +67,7 @@ describe(__filename, () => { }); mockTables = mock(TablesService); - when(mockTables.list(anything(), anything())).thenCall(() => { + when(mockTables.list(anything())).thenCall(() => { async function* impl() { yield { name: "t1", @@ -94,7 +96,7 @@ describe(__filename, () => { }); mockVolumes = mock(VolumesService); - when(mockVolumes.list(anything(), anything())).thenCall(() => { + when(mockVolumes.list(anything())).thenCall(() => { async function* impl() { yield { name: "v1", @@ -106,7 +108,7 @@ describe(__filename, () => { }); mockFunctions = mock(FunctionsService); - when(mockFunctions.list(anything(), anything())).thenCall(() => { + when(mockFunctions.list(anything())).thenCall(() => { async function* impl() { yield { name: "f1", @@ -118,7 +120,15 @@ describe(__filename, () => { }); mockRegisteredModels = mock(RegisteredModelsService); - when(mockRegisteredModels.list(anything(), anything())).thenCall(() => { + when(mockRegisteredModels.list(anything())).thenCall(() => { + async function* impl() { + /* empty */ + } + return impl(); + }); + + mockModelVersions = mock(ModelVersionsService); + when(mockModelVersions.list(anything())).thenCall(() => { async function* impl() { /* empty */ } @@ -134,6 +144,9 @@ describe(__filename, () => { when(mockWorkspaceClient.registeredModels).thenReturn( instance(mockRegisteredModels) ); + when(mockWorkspaceClient.modelVersions).thenReturn( + instance(mockModelVersions) + ); mockConnectionManager = mock(ConnectionManager); when(mockConnectionManager.workspaceClient).thenReturn( @@ -552,9 +565,14 @@ describe(__filename, () => { }); it("returns error when functions API throws", async () => { - when(mockFunctions.list(anything(), anything())).thenThrow( - new Error("functions API unavailable") - ); + when(mockFunctions.list(anything())).thenCall(() => { + async function* impl(): AsyncGenerator { + throw new Error("functions API unavailable"); + // eslint-disable-next-line no-unreachable + yield undefined as never; + } + return impl(); + }); const provider = new UnityCatalogTreeDataProvider( instance(mockConnectionManager), @@ -578,4 +596,155 @@ describe(__filename, () => { assert.notStrictEqual(children[0].kind, "error"); assert.strictEqual(children[children.length - 1].kind, "error"); }); + + it("lists registered models under a schema", async () => { + when(mockRegisteredModels.list(anything())).thenCall(() => { + async function* impl() { + yield {name: "m1", full_name: "cat.sch.m1"}; + } + return impl(); + }); + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + stubStateStorage + ); + disposables.push(provider); + const schema: UnityCatalogTreeNode = { + kind: "schema", + catalogName: "cat", + name: "sch", + fullName: "cat.sch", + }; + const children = (await resolveProviderResult( + provider.getChildren(schema) + )) as UnityCatalogTreeNode[]; + const model = children.find((c) => c.kind === "registeredModel"); + assert(model && model.kind === "registeredModel"); + assert.strictEqual(model.name, "m1"); + assert.strictEqual(model.fullName, "cat.sch.m1"); + }); + + it("lists model versions for a registered model, sorted descending", async () => { + when(mockModelVersions.list(anything())).thenCall(() => { + async function* impl() { + yield {version: 1}; + yield {version: 3}; + yield {version: 2}; + } + return impl(); + }); + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + stubStateStorage + ); + disposables.push(provider); + const modelNode: UnityCatalogTreeNode = { + kind: "registeredModel", + catalogName: "cat", + schemaName: "sch", + name: "m1", + fullName: "cat.sch.m1", + }; + const children = (await resolveProviderResult( + provider.getChildren(modelNode) + )) as UnityCatalogTreeNode[]; + assert(children); + assert.strictEqual(children.length, 3); + assert.strictEqual(children[0].kind, "modelVersion"); + if (children[0].kind === "modelVersion") { + assert.strictEqual(children[0].version, 3); + assert.strictEqual((children[2] as any).version, 1); + } + }); + + it("pinSchema adds fullName to stateStorage and fires tree change", async () => { + const stored: string[] = []; + const spyStorage = { + get: () => stored, + set: async (_key: string, val: string[]) => { + stored.length = 0; + stored.push(...val); + }, + onDidChange: () => ({dispose() {}}), + } as unknown as StateStorage; + const p = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + spyStorage + ); + disposables.push(p); + let fired = 0; + disposables.push(p.onDidChangeTreeData(() => {fired++;})); + const schema = { + kind: "schema" as const, + catalogName: "cat", + name: "sch", + fullName: "cat.sch", + }; + await p.pinSchema(schema); + assert(stored.includes("cat.sch")); + assert.strictEqual(fired, 1); + }); + + it("unpinSchema removes fullName from stateStorage and fires tree change", async () => { + const stored: string[] = ["cat.sch", "cat.other"]; + const spyStorage = { + get: () => [...stored], + set: async (_key: string, val: string[]) => { + stored.length = 0; + stored.push(...val); + }, + onDidChange: () => ({dispose() {}}), + } as unknown as StateStorage; + const p = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + spyStorage + ); + disposables.push(p); + let fired = 0; + disposables.push(p.onDidChangeTreeData(() => {fired++;})); + const schema = { + kind: "schema" as const, + catalogName: "cat", + name: "sch", + fullName: "cat.sch", + }; + await p.unpinSchema(schema); + assert(!stored.includes("cat.sch")); + assert(stored.includes("cat.other")); + assert.strictEqual(fired, 1); + }); + + it("pinned schema sorts before owned, owned before unowned", async () => { + const pinnedStorage = { + get: () => ["cat.s_b"], + set: async () => {}, + onDidChange: () => ({dispose() {}}), + } as unknown as StateStorage; + when(mockSchemas.list(anything())).thenCall(() => { + async function* impl() { + yield {name: "s_c", full_name: "cat.s_c", owner: "carol"}; + yield {name: "s_b", full_name: "cat.s_b", owner: "bob"}; // pinned + yield {name: "s_a", full_name: "cat.s_a", owner: "alice"}; // owned + } + return impl(); + }); + const stubManager = { + onDidChangeState: () => ({dispose() {}}), + workspaceClient: instance(mockWorkspaceClient), + databricksWorkspace: {user: {userName: "alice"}}, + } as unknown as ConnectionManager; + const p = new UnityCatalogTreeDataProvider(stubManager, pinnedStorage); + disposables.push(p); + const catalog: UnityCatalogTreeNode = { + kind: "catalog", + name: "cat", + fullName: "cat", + }; + const children = (await resolveProviderResult( + p.getChildren(catalog) + )) as UnityCatalogTreeNode[]; + assert.strictEqual((children[0] as any).name, "s_b"); // pinned first + assert.strictEqual((children[1] as any).name, "s_a"); // owned second + assert.strictEqual((children[2] as any).name, "s_c"); // unowned last + }); });