diff --git a/packages/databricks-vscode/package.json b/packages/databricks-vscode/package.json index 10a0ffa86..ade363ff9 100644 --- a/packages/databricks-vscode/package.json +++ b/packages/databricks-vscode/package.json @@ -564,12 +564,12 @@ { "id": "unityCatalogView", "name": "Unity Catalog", - "when": "databricks.context.activated && databricks.context.loggedIn" + "when": "databricks.context.activated && (databricks.context.loggedIn || databricks.context.remoteMode)" }, { "id": "databricksDocsView", "name": "Documentation", - "when": "databricks.context.activated && !databricks.context.remoteMode" + "when": "databricks.context.activated" } ] }, diff --git a/packages/databricks-vscode/src/configuration/ConnectionManager.test.ts b/packages/databricks-vscode/src/configuration/ConnectionManager.test.ts index 57801b5b5..1dc438ea0 100644 --- a/packages/databricks-vscode/src/configuration/ConnectionManager.test.ts +++ b/packages/databricks-vscode/src/configuration/ConnectionManager.test.ts @@ -1,24 +1,110 @@ /* eslint-disable @typescript-eslint/naming-convention */ +import assert from "assert"; import {Disposable} from "vscode"; +import {anything, instance, mock, reset, verify, when} from "ts-mockito"; +import {WorkspaceClient} from "@databricks/sdk-experimental"; +import {ConnectionManager} from "./ConnectionManager"; +import {ConfigModel} from "./models/ConfigModel"; +import {CliWrapper} from "../cli/CliWrapper"; +import {WorkspaceFolderManager} from "../vscode-objs/WorkspaceFolderManager"; +import {CustomWhenContext} from "../vscode-objs/CustomWhenContext"; +import {Telemetry} from "../telemetry"; +import {AuthProvider} from "./auth/AuthProvider"; describe(__filename, () => { let disposables: Array; + let mockCli: CliWrapper; + let mockConfigModel: ConfigModel; + let mockWorkspaceFolderManager: WorkspaceFolderManager; + let mockCustomWhenContext: CustomWhenContext; + let mockAuthProvider: AuthProvider; + let mockWorkspaceClient: WorkspaceClient; + + function buildConnectionManager(): ConnectionManager { + return new ConnectionManager( + instance(mockCli), + instance(mockConfigModel), + instance(mockWorkspaceFolderManager), + instance(mockCustomWhenContext), + new Telemetry() + ); + } + beforeEach(() => { disposables = []; + mockCli = mock(CliWrapper); + mockConfigModel = mock(ConfigModel); + mockWorkspaceFolderManager = mock(WorkspaceFolderManager); + mockCustomWhenContext = mock(CustomWhenContext); + mockAuthProvider = mock(); + mockWorkspaceClient = mock(WorkspaceClient); + + // DatabricksWorkspace.load() reads the org id from a header on the + // currentUser.me() response and (best-effort) the workspace conf. + when(mockWorkspaceClient.currentUser).thenReturn({ + me: async () => + ({ + "userName": "test@databricks.com", + "x-databricks-org-id": "1234", + }) as any, + } as any); + when(mockWorkspaceClient.apiClient).thenReturn(undefined as any); + when(mockAuthProvider.getWorkspaceClient()).thenResolve( + instance(mockWorkspaceClient) + ); + when(mockAuthProvider.host).thenReturn( + new URL("https://test.databricks.com") + ); }); afterEach(() => { disposables.forEach((d) => d.dispose()); + reset(mockConfigModel); + }); + + it("connectFromEnvironment connects using the injected auth provider", async () => { + const cm = buildConnectionManager(); + disposables.push(cm); + + await cm.connectFromEnvironment(instance(mockAuthProvider)); + + assert.equal(cm.state, "CONNECTED"); + assert.ok(cm.workspaceClient); + assert.ok(cm.databricksWorkspace); + assert.equal( + cm.databricksWorkspace?.host.toString(), + "https://test.databricks.com/" + ); + verify(mockCustomWhenContext.setLoggedIn(true)).atLeast(1); }); - // TODO - // login - // logout - // configure - // attach cluster - // detach cluster - // attach workspace - // detach workspace + it("connectFromEnvironment does not touch the config model (no bundle coupling)", async () => { + const cm = buildConnectionManager(); + disposables.push(cm); + + await cm.connectFromEnvironment(instance(mockAuthProvider)); + + verify(mockConfigModel.set(anything(), anything())).never(); + verify(mockConfigModel.setAuthProvider(anything())).never(); + }); + + it("connectFromEnvironment disconnects and rethrows on failure", async () => { + when(mockAuthProvider.getWorkspaceClient()).thenReject( + new Error("no credentials") + ); + const cm = buildConnectionManager(); + disposables.push(cm); + + await assert.rejects( + () => cm.connectFromEnvironment(instance(mockAuthProvider)), + /no credentials/ + ); + + assert.equal(cm.state, "DISCONNECTED"); + assert.equal(cm.workspaceClient, undefined); + assert.equal(cm.databricksWorkspace, undefined); + verify(mockCustomWhenContext.setLoggedIn(false)).atLeast(1); + }); }); diff --git a/packages/databricks-vscode/src/configuration/ConnectionManager.ts b/packages/databricks-vscode/src/configuration/ConnectionManager.ts index 6da1e99cc..2124d7c95 100644 --- a/packages/databricks-vscode/src/configuration/ConnectionManager.ts +++ b/packages/databricks-vscode/src/configuration/ConnectionManager.ts @@ -1,4 +1,5 @@ import { + Config, WorkspaceClient, ApiClient, logging, @@ -18,7 +19,12 @@ import {DatabricksWorkspace} from "./DatabricksWorkspace"; import {CustomWhenContext} from "../vscode-objs/CustomWhenContext"; import {ConfigModel} from "./models/ConfigModel"; import {onError, withOnErrorHandler} from "../utils/onErrorDecorator"; -import {AuthProvider, ProfileAuthProvider} from "./auth/AuthProvider"; +import { + AuthProvider, + PersonalAccessTokenAuthProvider, + ProfileAuthProvider, +} from "./auth/AuthProvider"; +import {normalizeHost} from "../utils/urlUtils"; import {Mutex} from "../locking"; import {MetadataService} from "./auth/MetadataService"; import {Events, Telemetry} from "../telemetry"; @@ -40,6 +46,7 @@ export type ConnectionState = "CONNECTED" | "CONNECTING" | "DISCONNECTED"; export class ConnectionManager implements Disposable { private disposables: Disposable[] = []; private _state: ConnectionState = "DISCONNECTED"; + private _connectionError?: string; private loginLogoutMutex: Mutex = new Mutex(); private savedAuthMutex: Mutex = new Mutex(); private configureLoginMutex: Mutex = new Mutex(); @@ -220,6 +227,16 @@ export class ConnectionManager implements Disposable { return this._state; } + /** + * The error message from the most recent failed connection attempt, if any. + * Cleared on a successful connection. Used to surface why an + * environment-based connection (remote mode) failed instead of showing an + * empty view. + */ + get connectionError(): string | undefined { + return this._connectionError; + } + get cluster(): Cluster | undefined { return this._clusterManager?.cluster; } @@ -261,6 +278,67 @@ export class ConnectionManager implements Disposable { } } + /** + * Connect using the host and token that the SDK resolves from the ambient + * environment (e.g. the DATABRICKS_HOST / DATABRICKS_TOKEN variables that + * the Databricks Remote SSH session injects). Only PAT credentials are + * supported here - the remote environment always provides a token, so we + * fail fast if one isn't present rather than attempting other auth types. + * + * Unlike the normal login flow this does not depend on a bundle/config + * project (host + target) and skips all sync/cluster/config machinery. It's + * used in Databricks Remote SSH sessions where only Unity Catalog is + * surfaced and credentials come from the environment. + */ + async connectFromEnvironment(authProvider?: AuthProvider): Promise { + await this.loginLogoutMutex.synchronise(async () => { + // We intentionally inline the connect/disconnect steps here rather + // than delegating to _connect()/disconnect(): both of those acquire + // loginLogoutMutex, which is non-reentrant, so calling them while we + // already hold it would deadlock. We also deliberately skip the + // sync/cluster/config-project machinery they run, since remote mode + // only needs a workspace client for the Unity Catalog view. + this._connectionError = undefined; + this.updateState("CONNECTING"); + try { + // The authProvider is only injected by tests; in production it + // is resolved from the SDK's default credential chain. + if (authProvider === undefined) { + const config = new Config({}); + await config.ensureResolved(); + if (config.host === undefined) { + throw new Error( + "No Databricks host found in the environment" + ); + } + if (config.token === undefined) { + throw new Error( + "No Databricks token found in the environment" + ); + } + authProvider = new PersonalAccessTokenAuthProvider( + normalizeHost(config.host), + config.token, + this.cli + ); + } + this._workspaceClient = await authProvider.getWorkspaceClient(); + this._databricksWorkspace = await DatabricksWorkspace.load( + this._workspaceClient, + authProvider + ); + this.updateState("CONNECTED"); + } catch (e) { + this._workspaceClient = undefined; + this._databricksWorkspace = undefined; + this._connectionError = + e instanceof Error ? e.message : String(e); + this.updateState("DISCONNECTED"); + throw e; + } + }); + } + private async loginWithSavedAuth(source: AutoLoginSource) { if (this.savedAuthMutex.locked) { return; diff --git a/packages/databricks-vscode/src/extension.ts b/packages/databricks-vscode/src/extension.ts index fd7e799ec..6b96a7c3a 100644 --- a/packages/databricks-vscode/src/extension.ts +++ b/packages/databricks-vscode/src/extension.ts @@ -92,6 +92,157 @@ const packageJson = require("../package.json"); const customWhenContext = new CustomWhenContext(); +/** + * Register the Unity Catalog tree view, its commands and the detail panel. + * Shared between the normal activation flow and the remote (Databricks Remote + * SSH) flow, where Unity Catalog is the only view we surface. + */ +function registerUnityCatalog( + context: ExtensionContext, + connectionManager: ConnectionManager, + stateStorage: StateStorage, + telemetry: Telemetry, + // In remote mode there is no automatic reconnection, so wire the refresh + // command to re-establish the connection before refreshing the tree. + reconnect?: () => Promise +): void { + const unityCatalogTreeDataProvider = new UnityCatalogTreeDataProvider( + connectionManager, + stateStorage, + context.extensionPath + ); + + const unityCatalogTreeView = window.createTreeView("unityCatalogView", { + treeDataProvider: unityCatalogTreeDataProvider, + }); + context.subscriptions.push( + unityCatalogTreeDataProvider, + unityCatalogTreeView, + telemetry.registerCommand( + "databricks.unityCatalog.refresh", + async () => { + if (reconnect) { + await reconnect(); + } + unityCatalogTreeDataProvider.refresh(); + } + ), + 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); + window.showInformationMessage("Copied to clipboard"); + } + } + ), + telemetry.registerCommand( + "databricks.unityCatalog.copyViewSql", + async (node: UnityCatalogTreeNode) => { + if (node.kind === "table" && node.viewDefinition) { + await env.clipboard.writeText(node.viewDefinition); + window.showInformationMessage("Copied to clipboard"); + } + } + ), + telemetry.registerCommand( + "databricks.unityCatalog.copyName", + async (node: UnityCatalogTreeNode) => { + if ( + node.kind === "error" || + node.kind === "empty" || + node.kind === "favorites" || + node.kind === "group" + ) { + 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) => { + if (node.kind === "error" || node.kind === "column") { + return; + } + const url = + unityCatalogTreeDataProvider.getNodeExploreUrl(node); + if (!url) { + window.showErrorMessage( + "Databricks: Can't open external link. No URL found." + ); + return; + } + await UrlUtils.openExternal(url); + } + ), + commands.registerCommand("databricks.unityCatalog.filter", async () => { + await commands.executeCommand("unityCatalogView.focus"); + await commands.executeCommand("list.find"); + }), + telemetry.registerCommand( + "databricks.unityCatalog.pin", + (node: UnityCatalogTreeNode) => { + if ( + node.kind === "catalog" || + node.kind === "schema" || + node.kind === "table" || + node.kind === "volume" || + node.kind === "function" || + node.kind === "registeredModel" || + node.kind === "modelVersion" + ) { + return unityCatalogTreeDataProvider.pin(node); + } + } + ), + telemetry.registerCommand( + "databricks.unityCatalog.unpin", + (node: UnityCatalogTreeNode) => { + if ( + node.kind === "catalog" || + node.kind === "schema" || + node.kind === "table" || + node.kind === "volume" || + node.kind === "function" || + node.kind === "registeredModel" || + node.kind === "modelVersion" + ) { + return unityCatalogTreeDataProvider.unpin(node); + } + } + ), + ...registerDetailPanel( + context.extensionUri, + connectionManager, + unityCatalogTreeView, + unityCatalogTreeDataProvider, + telemetry + ) + ); +} + +function registerDocsView(context: ExtensionContext): void { + const docsViewTreeDataProvider = new DocsViewTreeDataProvider(); + context.subscriptions.push( + window.registerTreeDataProvider( + "databricksDocsView", + docsViewTreeDataProvider + ), + docsViewTreeDataProvider + ); +} + export async function activate( context: ExtensionContext ): Promise { @@ -177,6 +328,33 @@ export async function activate( stateStorage ); + // Utils. Registered before the remote-mode branch below returns so that + // views shared between the normal and remote flows - such as the docs view, + // which invokes "databricks.utils.openExternal" - work in both. + const utilCommands = new UtilsCommands.UtilsCommands(telemetry); + context.subscriptions.push( + telemetry.registerCommand( + "databricks.utils.openExternal", + utilCommands.openExternalCommand(), + utilCommands + ), + telemetry.registerCommand( + "databricks.utils.goToDefinition", + utilCommands.goToDefinition(), + utilCommands + ), + telemetry.registerCommand( + "databricks.utils.copy", + utilCommands.copyToClipboardCommand(), + utilCommands + ), + telemetry.registerCommand("databricks.call", (fn) => { + if (fn) { + fn(); + } + }) + ); + // Add the databricks binary to the PATH environment variable in terminals context.environmentVariableCollection.clear(); context.environmentVariableCollection.persistent = false; @@ -267,6 +445,66 @@ export async function activate( ); } + // Surface only the Unity Catalog view and connect it using the ambient + // environment credentials (no bundle/config project required). The + // ConfigModel is only needed to satisfy the ConnectionManager + // constructor; connectFromEnvironment() never reads from it. + const remoteBundleFileSet = new BundleFileSet(workspaceFolderManager); + const remoteBundleFileWatcher = new BundleWatcher( + remoteBundleFileSet, + workspaceFolderManager + ); + const remoteConfigModel = new ConfigModel( + new BundleValidateModel( + remoteBundleFileWatcher, + cli, + workspaceFolderManager + ), + new OverrideableConfigModel(workspaceFolderManager), + new BundlePreValidateModel( + remoteBundleFileSet, + remoteBundleFileWatcher + ), + new BundleRemoteStateModel( + cli, + workspaceFolderManager, + workspaceConfigs + ), + customWhenContext, + stateStorage + ); + const remoteConnectionManager = new ConnectionManager( + cli, + remoteConfigModel, + workspaceFolderManager, + customWhenContext, + telemetry + ); + context.subscriptions.push( + remoteBundleFileWatcher, + remoteConfigModel, + remoteConnectionManager + ); + + const connectRemote = () => + remoteConnectionManager.connectFromEnvironment().catch((e) => { + logging.NamedLogger.getOrCreate(Loggers.Extension).error( + "Remote mode: failed to connect Unity Catalog", + e + ); + }); + + registerUnityCatalog( + context, + remoteConnectionManager, + stateStorage, + telemetry, + connectRemote + ); + registerDocsView(context); + + connectRemote(); + customWhenContext.setActivated(true); return; } @@ -490,126 +728,7 @@ export async function activate( ) ); - const unityCatalogTreeDataProvider = new UnityCatalogTreeDataProvider( - connectionManager, - stateStorage, - context.extensionPath - ); - - const unityCatalogTreeView = window.createTreeView("unityCatalogView", { - treeDataProvider: unityCatalogTreeDataProvider, - }); - context.subscriptions.push( - 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); - window.showInformationMessage("Copied to clipboard"); - } - } - ), - telemetry.registerCommand( - "databricks.unityCatalog.copyViewSql", - async (node: UnityCatalogTreeNode) => { - if (node.kind === "table" && node.viewDefinition) { - await env.clipboard.writeText(node.viewDefinition); - window.showInformationMessage("Copied to clipboard"); - } - } - ), - telemetry.registerCommand( - "databricks.unityCatalog.copyName", - async (node: UnityCatalogTreeNode) => { - if ( - node.kind === "error" || - node.kind === "empty" || - node.kind === "favorites" || - node.kind === "group" - ) { - 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) => { - if (node.kind === "error" || node.kind === "column") { - return; - } - const url = - unityCatalogTreeDataProvider.getNodeExploreUrl(node); - if (!url) { - window.showErrorMessage( - "Databricks: Can't open external link. No URL found." - ); - return; - } - await UrlUtils.openExternal(url); - } - ), - commands.registerCommand("databricks.unityCatalog.filter", async () => { - await commands.executeCommand("unityCatalogView.focus"); - await commands.executeCommand("list.find"); - }), - telemetry.registerCommand( - "databricks.unityCatalog.pin", - (node: UnityCatalogTreeNode) => { - if ( - node.kind === "catalog" || - node.kind === "schema" || - node.kind === "table" || - node.kind === "volume" || - node.kind === "function" || - node.kind === "registeredModel" || - node.kind === "modelVersion" - ) { - return unityCatalogTreeDataProvider.pin(node); - } - } - ), - telemetry.registerCommand( - "databricks.unityCatalog.unpin", - (node: UnityCatalogTreeNode) => { - if ( - node.kind === "catalog" || - node.kind === "schema" || - node.kind === "table" || - node.kind === "volume" || - node.kind === "function" || - node.kind === "registeredModel" || - node.kind === "modelVersion" - ) { - return unityCatalogTreeDataProvider.unpin(node); - } - } - ), - ...registerDetailPanel( - context.extensionUri, - connectionManager, - unityCatalogTreeView, - unityCatalogTreeDataProvider, - telemetry - ) - ); + registerUnityCatalog(context, connectionManager, stateStorage, telemetry); const configureAutocomplete = new ConfigureAutocomplete( context, @@ -1079,14 +1198,7 @@ export async function activate( debugWorkflowFactory ); - const docsViewTreeDataProvider = new DocsViewTreeDataProvider(); - context.subscriptions.push( - window.registerTreeDataProvider( - "databricksDocsView", - docsViewTreeDataProvider - ), - docsViewTreeDataProvider - ); + registerDocsView(context); showQuickStartOnFirstUse(context).catch((e) => { logging.NamedLogger.getOrCreate("Extension").error( @@ -1095,31 +1207,6 @@ export async function activate( ); }); - // Utils - const utilCommands = new UtilsCommands.UtilsCommands(telemetry); - context.subscriptions.push( - telemetry.registerCommand( - "databricks.utils.openExternal", - utilCommands.openExternalCommand(), - utilCommands - ), - telemetry.registerCommand( - "databricks.utils.goToDefinition", - utilCommands.goToDefinition(), - utilCommands - ), - telemetry.registerCommand( - "databricks.utils.copy", - utilCommands.copyToClipboardCommand(), - utilCommands - ), - telemetry.registerCommand("databricks.call", (fn) => { - if (fn) { - fn(); - } - }) - ); - // generate a json schema for bundle root and load a custom provider into // redhat.vscode-yaml extension to validate bundle config files with this schema registerBundleAutocompleteProvider( 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 0f8f2d6ef..739e0ba84 100644 --- a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.test.ts +++ b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.test.ts @@ -175,15 +175,53 @@ describe(__filename, () => { disposables.forEach((d) => d.dispose()); }); - it("returns undefined when not connected", async () => { + it("shows a status node at the root when not connected", async () => { when(mockConnectionManager.workspaceClient).thenReturn(undefined); + when(mockConnectionManager.state).thenReturn("DISCONNECTED"); + when(mockConnectionManager.connectionError).thenReturn(undefined); const provider = new UnityCatalogTreeDataProvider( instance(mockConnectionManager), stubStateStorage ); disposables.push(provider); - const children = await resolveProviderResult(provider.getChildren()); + const children = (await resolveProviderResult( + provider.getChildren() + )) as UnityCatalogTreeNode[]; + assert.strictEqual(children.length, 1); + assert.strictEqual(children[0].kind, "empty"); + }); + + it("shows the connection error at the root when not connected", async () => { + when(mockConnectionManager.workspaceClient).thenReturn(undefined); + when(mockConnectionManager.state).thenReturn("DISCONNECTED"); + when(mockConnectionManager.connectionError).thenReturn( + "No Databricks host found in the environment" + ); + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + stubStateStorage + ); + disposables.push(provider); + + const children = (await resolveProviderResult( + provider.getChildren() + )) as UnityCatalogTreeNode[]; + assert.strictEqual(children.length, 1); + assert.strictEqual(children[0].kind, "error"); + }); + + it("returns undefined for child nodes when not connected", async () => { + when(mockConnectionManager.workspaceClient).thenReturn(undefined); + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + stubStateStorage + ); + disposables.push(provider); + + const children = await resolveProviderResult( + provider.getChildren({kind: "favorites"}) + ); assert.strictEqual(children, undefined); }); diff --git a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts index d4dd52d4f..a42aa1452 100644 --- a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts +++ b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts @@ -149,7 +149,13 @@ export class UnityCatalogTreeDataProvider ): Promise { const client = this.connectionManager.workspaceClient; if (!client) { - return undefined; + // Without a workspace client there is nothing to load. Only surface + // a status node at the root (child requests just return nothing) so + // the view explains why it's empty instead of rendering blank. + if (element) { + return undefined; + } + return this.getDisconnectedRootChildren(); } const currentUser = this.connectionManager.databricksWorkspace?.user; @@ -186,6 +192,17 @@ export class UnityCatalogTreeDataProvider return undefined; } + private getDisconnectedRootChildren(): UnityCatalogTreeNode[] { + if (this.connectionManager.state === "CONNECTING") { + return [{kind: "empty", message: "Connecting…"}]; + } + const error = this.connectionManager.connectionError; + if (error) { + return [{kind: "error", message: `Not connected: ${error}`}]; + } + return [{kind: "empty", message: "Not connected"}]; + } + private async getRootChildren( client: NonNullable, currentUser: