From 6b5762ed16a0192a5e829cf206b748668b390e43 Mon Sep 17 00:00:00 2001 From: Ilia Babanov Date: Thu, 16 Jan 2025 22:52:49 +0100 Subject: [PATCH] Bundle selection ux (#1519) ## Changes When you land on a workspace without dabs config in the root, you can now select a sub-folder as the active bundle folder (without changing the workspace root, and without reloading the whole IDE). Screenshot 2025-01-16 at 10 40 16 The above is for the case when we can't find any sub-projects ourselves. Clicking on the top button opens an OS folder selection dialog. When we can detect sub projects, we show the "select sub-folder" button on top: Screenshot 2025-01-16 at 10 39 44 When you select the sub-folder and initialise the extension, we now show additional "Local Folder" UI at the top of the configuration: Screenshot 2025-01-14 at 10 11 09 Screenshot 2025-01-14 at 10 17 39 The UI for selecting sub-folders: Screenshot 2025-01-14 at 10 11 52 "Select another folder" opens a OS-level selection dialog. If you select a folder that's part of the workspace, we don't reload the IDE and just point our extension to it. When the selected folder is outside of the workspace, we reload the IDE with the folder being the new workspace root. After you select some sub-folder it's saved to workspace-scoped vscode storage, so the next time this workspace is opened our extension already knows what sub-folder to use. After the extension is initialised, you can now easily change the active bundle folder if you have multiple bundles in different sub-folders. ## Tests Manually and with a few new unit tests, e2e tests will be in the follow up PR --------- Co-authored-by: Julia Crawford (Databricks) --- packages/databricks-vscode/package.json | 35 +++- .../resources/python/dbconnect-bootstrap.py | 35 ++-- .../src/bundle/BundleFileSet.test.ts | 4 +- .../src/bundle/BundleFileSet.ts | 41 +---- .../src/bundle/BundleInitWizard.ts | 52 +----- .../src/bundle/BundleProjectManager.ts | 24 ++- .../src/bundle/BundleWatcher.ts | 4 +- .../src/bundle/activeBundleUtils.ts | 81 +++++++++ .../bundle/models/BundleRemoteStateModel.ts | 14 +- .../src/bundle/models/BundleValidateModel.ts | 4 +- .../src/bundle/models/BundleVariableModel.ts | 6 +- .../src/cluster/ClusterModel.ts | 2 +- .../src/configuration/ConnectionManager.ts | 10 +- .../models/OverrideableConfigModel.ts | 8 +- packages/databricks-vscode/src/extension.ts | 19 +-- .../file-managers/DatabricksEnvFileManager.ts | 16 +- .../src/language/ConfigureAutocomplete.ts | 8 +- .../src/language/MsPythonExtensionWrapper.ts | 2 +- .../notebooks/DatabricksNbCellLimits.ts | 2 +- .../notebooks/NotebookInitScriptManager.ts | 5 +- .../src/test/e2e/bundle_sub_folder.e2e.ts | 127 ++++++++++++++ .../src/test/e2e/deploy_and_run_job.e2e.ts | 12 +- .../src/test/e2e/destroy.e2e.ts | 74 +-------- ...esh_resource_explorer_on_yml_change.e2e.ts | 69 +------- .../src/test/e2e/utils/commonUtils.ts | 3 +- .../src/test/e2e/utils/dabsFixtures.ts | 4 +- .../databricks-vscode/src/test/runTest.ts | 4 +- .../SyncDestinationComponent.ts | 5 +- .../WorkspaceFolderComponent.ts | 22 ++- .../src/utils/envVarGenerators.ts | 4 +- .../src/vscode-objs/StateStorage.ts | 4 + .../WorkspaceFolderManager.test.ts | 70 ++++++++ .../src/vscode-objs/WorkspaceFolderManager.ts | 156 ++++++++---------- .../src/workspace-fs/WorkspaceFsCommands.ts | 2 +- 34 files changed, 519 insertions(+), 409 deletions(-) create mode 100644 packages/databricks-vscode/src/bundle/activeBundleUtils.ts create mode 100644 packages/databricks-vscode/src/test/e2e/bundle_sub_folder.e2e.ts create mode 100644 packages/databricks-vscode/src/vscode-objs/WorkspaceFolderManager.test.ts diff --git a/packages/databricks-vscode/package.json b/packages/databricks-vscode/package.json index 1d4a7a5dc..832348f97 100644 --- a/packages/databricks-vscode/package.json +++ b/packages/databricks-vscode/package.json @@ -203,6 +203,13 @@ "enablement": "databricks.context.activated", "category": "Databricks" }, + { + "command": "databricks.bundle.selectActiveProjectFolder", + "icon": "$(gear)", + "title": "Select a Databricks project folder", + "enablement": "databricks.context.activated && databricks.context.bundle.deploymentState == idle", + "category": "Databricks" + }, { "command": "databricks.bundle.refreshRemoteState", "icon": "$(refresh)", @@ -444,17 +451,22 @@ }, { "view": "configurationView", - "contents": "There are multiple Databricks projects in the folder:\n[Open existing Databricks Project](command:databricks.bundle.openSubProject)", + "contents": "Detected multiple Databricks projects in the VSCode workspace:\n[Select a project](command:databricks.bundle.selectActiveProjectFolder)", "when": "workspaceFolderCount > 0 && databricks.context.initialized && databricks.context.subProjectsAvailable" }, { "view": "configurationView", - "contents": "Migrate current folder to a Databricks Project: \n[Migrate current folder to a Databricks Project](command:databricks.bundle.startManualMigration)", + "contents": "No Databricks project configuration detected in the root of the VSCode workspace:\n[Create configuration](command:databricks.bundle.startManualMigration)", "when": "workspaceFolderCount > 0 && databricks.context.initialized && databricks.context.pendingManualMigration" }, { "view": "configurationView", - "contents": "[Create a new Databricks Project](command:databricks.bundle.initNewProject)", + "contents": "No Databricks projects detected in the VSCode workspace:\n[Select a project manually](command:databricks.bundle.selectActiveProjectFolder)", + "when": "workspaceFolderCount > 0 && databricks.context.initialized && !databricks.context.isBundleProject && !databricks.context.subProjectsAvailable" + }, + { + "view": "configurationView", + "contents": "Chose a new parent folder and create a Databricks project based on a [template](https://docs.databricks.com/en/dev-tools/bundles/templates.html#databricks-asset-bundle-project-templates):\n[Create a new project](command:databricks.bundle.initNewProject)", "when": "workspaceFolderCount > 0 && databricks.context.initialized && !databricks.context.isBundleProject" }, { @@ -464,7 +476,12 @@ }, { "view": "configurationView", - "contents": "This folder is empty.\n[Create a new Databricks Project](command:databricks.bundle.initNewProject)", + "contents": "This folder is empty.\n[Open folder](command:vscode.openFolder)", + "when": "workspaceFolderCount == 0" + }, + { + "view": "configurationView", + "contents": "[Create a new Databricks project](command:databricks.bundle.initNewProject)", "when": "workspaceFolderCount == 0" }, { @@ -559,6 +576,16 @@ "when": "viewItem =~ /^databricks.*\\.(has-url).*$/ && databricks.context.bundle.deploymentState == idle", "group": "navigation_2@0" }, + { + "command": "databricks.bundle.selectActiveProjectFolder", + "when": "viewItem =~ /^databricks.configuration.activeProjectFolder/ && databricks.context.bundle.deploymentState == idle", + "group": "navigation_2@0" + }, + { + "command": "databricks.bundle.selectActiveProjectFolder", + "when": "viewItem =~ /^databricks.configuration.activeProjectFolder/ && databricks.context.bundle.deploymentState == idle", + "group": "inline@2" + }, { "command": "databricks.connection.attachCluster", "when": "view == clusterView && databricks.context.bundle.deploymentState == idle", diff --git a/packages/databricks-vscode/resources/python/dbconnect-bootstrap.py b/packages/databricks-vscode/resources/python/dbconnect-bootstrap.py index 5c6278274..1ca07eaec 100644 --- a/packages/databricks-vscode/resources/python/dbconnect-bootstrap.py +++ b/packages/databricks-vscode/resources/python/dbconnect-bootstrap.py @@ -3,23 +3,19 @@ import logging from runpy import run_path -# Load environment variables from .databricks/.databricks.env -# We only look for the folder in the current working directory -# since for all commands laucnhed from root workspace -def load_env_file_from_cwd(path: str): - if not os.path.isdir(path): - return - - env_file_path = os.path.join(path, ".databricks", ".databricks.env") - if not os.path.exists(os.path.dirname(env_file_path)): - return - - with open(env_file_path, "r") as f: - for line in f.readlines(): - key, value = line.strip().split("=", 1) - os.environ[key] = value - return - +def load_env_from_leaf(path: str) -> bool: + curdir = path if os.path.isdir(path) else os.path.dirname(path) + env_file_path = os.path.join(curdir, ".databricks", ".databricks.env") + if os.path.exists(env_file_path): + with open(env_file_path, "r") as f: + for line in f.readlines(): + key, value = line.strip().split("=", 1) + os.environ[key] = value + return curdir + parent = os.path.dirname(curdir) + if parent == curdir: + return curdir + return load_env_from_leaf(parent) script = sys.argv[1] sys.argv = sys.argv[1:] @@ -35,8 +31,7 @@ def load_env_file_from_cwd(path: str): # Suppress grpc warnings coming from databricks-connect with newer version of grpcio lib os.environ["GRPC_VERBOSITY"] = "NONE" -root_dir = os.getcwd() -load_env_file_from_cwd(root_dir) +project_dir = load_env_from_leaf(cur_dir) log_level = os.environ.get("DATABRICKS_VSCODE_LOG_LEVEL") log_level = log_level if log_level is not None else "WARN" @@ -68,7 +63,7 @@ def getArgument(*args, **kwargs): db_globals['getArgument'] = getArgument -sys.path.insert(0, root_dir) +sys.path.insert(0, project_dir) sys.path.insert(0, cur_dir) run_path(script, init_globals=db_globals, run_name="__main__") diff --git a/packages/databricks-vscode/src/bundle/BundleFileSet.test.ts b/packages/databricks-vscode/src/bundle/BundleFileSet.test.ts index b1a99a621..05bf2a275 100644 --- a/packages/databricks-vscode/src/bundle/BundleFileSet.test.ts +++ b/packages/databricks-vscode/src/bundle/BundleFileSet.test.ts @@ -23,10 +23,12 @@ describe(__filename, async function () { function getWorkspaceFolderManagerMock() { const mockWorkspaceFolderManager = mock(); const mockWorkspaceFolder = mock(); - when(mockWorkspaceFolder.uri).thenReturn(Uri.file(tmpdir.path)); + const uri = Uri.file(tmpdir.path); + when(mockWorkspaceFolder.uri).thenReturn(uri); when(mockWorkspaceFolderManager.activeWorkspaceFolder).thenReturn( instance(mockWorkspaceFolder) ); + when(mockWorkspaceFolderManager.activeProjectUri).thenReturn(uri); return instance(mockWorkspaceFolderManager); } diff --git a/packages/databricks-vscode/src/bundle/BundleFileSet.ts b/packages/databricks-vscode/src/bundle/BundleFileSet.ts index a2311cde9..1ff6b8af7 100644 --- a/packages/databricks-vscode/src/bundle/BundleFileSet.ts +++ b/packages/databricks-vscode/src/bundle/BundleFileSet.ts @@ -62,21 +62,21 @@ export class BundleFileSet { return bundle as BundleSchema; }); - private get workspaceRoot() { - return this.workspaceFolderManager.activeWorkspaceFolder.uri; + private get projectRoot() { + return this.workspaceFolderManager.activeProjectUri; } constructor( private readonly workspaceFolderManager: WorkspaceFolderManager ) { - workspaceFolderManager.onDidChangeActiveWorkspaceFolder(() => { + workspaceFolderManager.onDidChangeActiveProjectFolder(() => { this.bundleDataCache.invalidate(); }); } async getRootFile() { const rootFile = await glob.glob( - getAbsoluteGlobPath(rootFilePattern, this.workspaceRoot), + getAbsoluteGlobPath(rootFilePattern, this.projectRoot), {nocase: process.platform === "win32"} ); if (rootFile.length !== 1) { @@ -85,33 +85,6 @@ export class BundleFileSet { return Uri.file(rootFile[0]); } - async getSubProjects( - root?: Uri - ): Promise<{relative: Uri; absolute: Uri}[]> { - const subProjectRoots = await glob.glob( - getAbsoluteGlobPath( - subProjectFilePattern, - root || this.workspaceRoot - ), - {nocase: process.platform === "win32"} - ); - const normalizedRoot = path.normalize( - root?.fsPath ?? this.workspaceRoot.fsPath - ); - return subProjectRoots - .map((rootFile) => { - const dirname = path.dirname(path.normalize(rootFile)); - const absolute = Uri.file(dirname); - const relative = Uri.file( - absolute.fsPath.replace(normalizedRoot, "") - ); - return {absolute, relative}; - }) - .filter(({absolute}) => { - return absolute.fsPath !== normalizedRoot; - }); - } - async getIncludedFilesGlob() { const rootFile = await this.getRootFile(); if (rootFile === undefined) { @@ -133,7 +106,7 @@ export class BundleFileSet { return ( await glob.glob( toGlobPath( - path.join(this.workspaceRoot.fsPath, includedFilesGlob) + path.join(this.projectRoot.fsPath, includedFilesGlob) ), {nocase: process.platform === "win32"} ) @@ -171,7 +144,7 @@ export class BundleFileSet { isRootBundleFile(e: Uri) { return minimatch( e.fsPath, - getAbsoluteGlobPath(rootFilePattern, this.workspaceRoot) + getAbsoluteGlobPath(rootFilePattern, this.projectRoot) ); } @@ -182,7 +155,7 @@ export class BundleFileSet { } includedFilesGlob = getAbsoluteGlobPath( includedFilesGlob, - this.workspaceRoot + this.projectRoot ); return minimatch(e.fsPath, toGlobPath(includedFilesGlob)); } diff --git a/packages/databricks-vscode/src/bundle/BundleInitWizard.ts b/packages/databricks-vscode/src/bundle/BundleInitWizard.ts index 56d2471de..454fb094e 100644 --- a/packages/databricks-vscode/src/bundle/BundleInitWizard.ts +++ b/packages/databricks-vscode/src/bundle/BundleInitWizard.ts @@ -15,48 +15,9 @@ import {getSubProjects} from "./BundleFileSet"; import {tmpdir} from "os"; import {ShellUtils} from "../utils"; import {Events, Telemetry} from "../telemetry"; -import {OverrideableConfigModel} from "../configuration/models/OverrideableConfigModel"; -import {writeFile, mkdir} from "fs/promises"; -import path from "path"; import {escapePathArgument} from "../utils/shellUtils"; - -export async function promptToOpenSubProjects( - projects: {absolute: Uri; relative: string}[], - authProvider?: AuthProvider -) { - type OpenProjectItem = QuickPickItem & {uri?: Uri}; - const items: OpenProjectItem[] = projects.map((project) => { - return { - uri: project.absolute, - label: project.relative, - detail: project.absolute.fsPath, - }; - }); - items.push( - {label: "", kind: QuickPickItemKind.Separator}, - {label: "Choose another folder"} - ); - const options = { - title: "Select the project you want to open", - }; - const item = await window.showQuickPick(items, options); - if (!item?.uri) { - return; - } - - if (authProvider?.authType === "profile") { - const rootOverrideFilePath = - OverrideableConfigModel.getRootOverrideFile(item.uri); - await mkdir(path.dirname(rootOverrideFilePath.fsPath), { - recursive: true, - }); - await writeFile( - rootOverrideFilePath.fsPath, - JSON.stringify({authProfile: authProvider.toJSON().profile}) - ); - } - await commands.executeCommand("vscode.openFolder", item.uri); -} +import {promptToSelectActiveProjectFolder} from "./activeBundleUtils"; +import {WorkspaceFolderManager} from "../vscode-objs/WorkspaceFolderManager"; export class BundleInitWizard { private logger = logging.NamedLogger.getOrCreate(Loggers.Extension); @@ -68,7 +29,8 @@ export class BundleInitWizard { public async initNewProject( workspaceUri?: Uri, - existingAuthProvider?: AuthProvider + existingAuthProvider?: AuthProvider, + workspaceFolderManager?: WorkspaceFolderManager ) { const recordEvent = this.telemetry.start(Events.BUNDLE_INIT); try { @@ -97,7 +59,11 @@ export class BundleInitWizard { this.logger.debug( `Detected ${projects.length} sub projects after the init wizard, prompting to open one` ); - await promptToOpenSubProjects(projects, authProvider); + await promptToSelectActiveProjectFolder( + projects, + authProvider, + workspaceFolderManager + ); } else { this.logger.debug( `No projects detected after the init wizard, showing notification to open a folder manually` diff --git a/packages/databricks-vscode/src/bundle/BundleProjectManager.ts b/packages/databricks-vscode/src/bundle/BundleProjectManager.ts index a73070608..f8de67188 100644 --- a/packages/databricks-vscode/src/bundle/BundleProjectManager.ts +++ b/packages/databricks-vscode/src/bundle/BundleProjectManager.ts @@ -16,9 +16,10 @@ import {ProfileAuthProvider} from "../configuration/auth/AuthProvider"; import {ProjectConfigFile} from "../file-managers/ProjectConfigFile"; import {randomUUID} from "crypto"; import {onError} from "../utils/onErrorDecorator"; -import {BundleInitWizard, promptToOpenSubProjects} from "./BundleInitWizard"; +import {BundleInitWizard} from "./BundleInitWizard"; import {EventReporter, Events, Telemetry} from "../telemetry"; import {WorkspaceFolderManager} from "../vscode-objs/WorkspaceFolderManager"; +import {promptToSelectActiveProjectFolder} from "./activeBundleUtils"; export class BundleProjectManager { private logger = logging.NamedLogger.getOrCreate(Loggers.Extension); @@ -52,7 +53,7 @@ export class BundleProjectManager { private telemetry: Telemetry ) { this.disposables.push( - this.workspaceFolderManager.onDidChangeActiveWorkspaceFolder( + this.workspaceFolderManager.onDidChangeActiveProjectFolder( async () => { await this.isBundleProjectCache.refresh(); } @@ -162,10 +163,18 @@ export class BundleProjectManager { }); } - public async openSubProjects() { - if (this.subProjects && this.subProjects.length > 0) { - return promptToOpenSubProjects(this.subProjects); - } + public async selectActiveProjectFolder() { + return window.withProgress( + {location: {viewId: "configurationView"}}, + async () => { + await this.detectSubProjects(); + return promptToSelectActiveProjectFolder( + this.subProjects ?? [], + undefined, + this.workspaceFolderManager + ); + } + ); } private setPendingManualMigration() { @@ -328,7 +337,8 @@ export class BundleProjectManager { this.connectionManager.databricksWorkspace?.authProvider; const parentFolder = await bundleInitWizard.initNewProject( this.workspaceUri, - authProvider + authProvider, + this.workspaceFolderManager ); if (parentFolder) { await this.isBundleProjectCache.refresh(); diff --git a/packages/databricks-vscode/src/bundle/BundleWatcher.ts b/packages/databricks-vscode/src/bundle/BundleWatcher.ts index fe24b9c7b..3eca980eb 100644 --- a/packages/databricks-vscode/src/bundle/BundleWatcher.ts +++ b/packages/databricks-vscode/src/bundle/BundleWatcher.ts @@ -25,7 +25,7 @@ export class BundleWatcher implements Disposable { ) { this.initCleanup = this.init(); this.disposables.push( - this.workspaceFolderManager.onDidChangeActiveWorkspaceFolder(() => { + this.workspaceFolderManager.onDidChangeActiveProjectFolder(() => { this.initCleanup.dispose(); this.initCleanup = this.init(); this.bundleFileSet.bundleDataCache.invalidate(); @@ -37,7 +37,7 @@ export class BundleWatcher implements Disposable { const yamlWatcher = workspace.createFileSystemWatcher( getAbsoluteGlobPath( path.join("**", "*.{yaml,yml}"), - this.workspaceFolderManager.activeWorkspaceFolder.uri + this.workspaceFolderManager.activeProjectUri ) ); diff --git a/packages/databricks-vscode/src/bundle/activeBundleUtils.ts b/packages/databricks-vscode/src/bundle/activeBundleUtils.ts new file mode 100644 index 000000000..15c98c1b2 --- /dev/null +++ b/packages/databricks-vscode/src/bundle/activeBundleUtils.ts @@ -0,0 +1,81 @@ +import { + QuickPickItem, + QuickPickItemKind, + Uri, + window, + commands, + workspace, +} from "vscode"; +import {AuthProvider} from "../configuration/auth/AuthProvider"; +import {OverrideableConfigModel} from "../configuration/models/OverrideableConfigModel"; +import {writeFile, mkdir} from "fs/promises"; +import path from "path"; +import {WorkspaceFolderManager} from "../vscode-objs/WorkspaceFolderManager"; + +export async function promptToSelectActiveProjectFolder( + projects: {absolute: Uri; relative: string}[], + authProvider?: AuthProvider, + workspaceFolderManager?: WorkspaceFolderManager +) { + let uri: Uri | undefined; + + type OpenProjectItem = QuickPickItem & {uri?: Uri}; + const items: OpenProjectItem[] = projects.map((project) => { + return { + uri: project.absolute, + label: project.relative, + detail: project.absolute.fsPath, + }; + }); + + if (items.length > 0) { + items.push( + {label: "", kind: QuickPickItemKind.Separator}, + {label: "Choose another folder"} + ); + const options = { + title: "Select the project you want to open", + }; + const item = await window.showQuickPick( + items, + options + ); + if (!item) { + return; + } + uri = item.uri; + } + + if (!uri) { + const folders = await window.showOpenDialog({ + canSelectFolders: true, + canSelectMany: false, + }); + if (folders) { + uri = folders[0]; + } + } + + if (!uri) { + return; + } + + if (authProvider?.authType === "profile") { + const rootOverrideFilePath = + OverrideableConfigModel.getRootOverrideFile(uri); + await mkdir(path.dirname(rootOverrideFilePath.fsPath), { + recursive: true, + }); + await writeFile( + rootOverrideFilePath.fsPath, + JSON.stringify({authProfile: authProvider.toJSON().profile}) + ); + } + + const workspaceFolder = workspace.getWorkspaceFolder(uri); + if (!workspaceFolderManager || !workspaceFolder) { + await commands.executeCommand("vscode.openFolder", uri); + } else { + workspaceFolderManager.setActiveProjectFolder(uri, workspaceFolder); + } +} diff --git a/packages/databricks-vscode/src/bundle/models/BundleRemoteStateModel.ts b/packages/databricks-vscode/src/bundle/models/BundleRemoteStateModel.ts index 4ef8b88df..0aacebd90 100644 --- a/packages/databricks-vscode/src/bundle/models/BundleRemoteStateModel.ts +++ b/packages/databricks-vscode/src/bundle/models/BundleRemoteStateModel.ts @@ -46,8 +46,8 @@ export class BundleRemoteStateModel extends BaseModelWithStateCache { await this.configModel.setTarget(undefined); @@ -259,7 +259,7 @@ export class ConnectionManager implements Disposable { private async loadLegacyProjectConfig() { try { - return await ProjectConfigFile.loadConfig(this.workspaceUri.fsPath); + return await ProjectConfigFile.loadConfig(this.projectRoot.fsPath); } catch (error) { const logger = NamedLogger.getOrCreate("Extension"); logger.error(`Error loading legacy config`, error); diff --git a/packages/databricks-vscode/src/configuration/models/OverrideableConfigModel.ts b/packages/databricks-vscode/src/configuration/models/OverrideableConfigModel.ts index 14a381576..45ae1f0af 100644 --- a/packages/databricks-vscode/src/configuration/models/OverrideableConfigModel.ts +++ b/packages/databricks-vscode/src/configuration/models/OverrideableConfigModel.ts @@ -50,7 +50,7 @@ export class OverrideableConfigModel extends BaseModelWithStateCache { + workspaceFolderManager.onDidChangeActiveProjectFolder(() => { databricksEnvFileManager.dispose(); databricksEnvFileManager.init(); }), @@ -916,7 +909,7 @@ export async function activate( }; configureWorkspace(); - workspaceFolderManager.onDidChangeActiveWorkspaceFolder(configureWorkspace); + workspaceFolderManager.onDidChangeActiveProjectFolder(configureWorkspace); customWhenContext.setActivated(true); telemetry.recordEvent(Events.EXTENSION_ACTIVATION); diff --git a/packages/databricks-vscode/src/file-managers/DatabricksEnvFileManager.ts b/packages/databricks-vscode/src/file-managers/DatabricksEnvFileManager.ts index 360deb580..8e2840975 100644 --- a/packages/databricks-vscode/src/file-managers/DatabricksEnvFileManager.ts +++ b/packages/databricks-vscode/src/file-managers/DatabricksEnvFileManager.ts @@ -22,17 +22,13 @@ export class DatabricksEnvFileManager implements Disposable { private showDatabricksConnectProgess = true; get databricksEnvPath() { - return Uri.joinPath( - this.workspacePath, - ".databricks", - ".databricks.env" - ); + return Uri.joinPath(this.projectRoot, ".databricks", ".databricks.env"); } private get systemVariableResolver() { - return new SystemVariables(this.workspacePath); + return new SystemVariables(this.projectRoot); } - private get workspacePath() { - return this.workspaceFolderManager.activeWorkspaceFolder.uri; + private get projectRoot() { + return this.workspaceFolderManager.activeProjectUri; } private readonly onDidChangeEnvironmentVariablesEmitter = @@ -101,7 +97,7 @@ export class DatabricksEnvFileManager implements Disposable { public async init() { await FileUtils.waitForDatabricksProject( - this.workspacePath, + this.projectRoot, this.connectionManager ); @@ -182,7 +178,7 @@ export class DatabricksEnvFileManager implements Disposable { ...(this.getDatabrickseEnvVars() || {}), ...((await EnvVarGenerators.getDbConnectEnvVars( this.connectionManager, - this.workspacePath, + this.projectRoot, this.showDatabricksConnectProgess )) || {}), ...this.getIdeEnvVars(), diff --git a/packages/databricks-vscode/src/language/ConfigureAutocomplete.ts b/packages/databricks-vscode/src/language/ConfigureAutocomplete.ts index f7fa504de..6050e3eda 100644 --- a/packages/databricks-vscode/src/language/ConfigureAutocomplete.ts +++ b/packages/databricks-vscode/src/language/ConfigureAutocomplete.ts @@ -174,8 +174,8 @@ export class ConfigureAutocomplete implements Disposable { this.environmentDependenciesInstaller.show(false); } - private get workspaceFolder() { - return this.workspaceFolderManager.activeWorkspaceFolder.uri.fsPath; + private get projectRootPath() { + return this.workspaceFolderManager.activeProjectUri.fsPath; } private async addBuiltinsFile(dryRun = false): Promise { @@ -184,8 +184,8 @@ export class ConfigureAutocomplete implements Disposable { .get("analysis.stubPath"); const builtinsDir = stubPath - ? path.join(this.workspaceFolder, stubPath) - : this.workspaceFolder; + ? path.join(this.projectRootPath, stubPath) + : this.projectRootPath; let builtinsFileExists = false; try { diff --git a/packages/databricks-vscode/src/language/MsPythonExtensionWrapper.ts b/packages/databricks-vscode/src/language/MsPythonExtensionWrapper.ts index 7346d6165..8f85e7471 100644 --- a/packages/databricks-vscode/src/language/MsPythonExtensionWrapper.ts +++ b/packages/databricks-vscode/src/language/MsPythonExtensionWrapper.ts @@ -87,7 +87,7 @@ export class MsPythonExtensionWrapper implements Disposable { get pythonEnvironment() { return this.api.environments?.resolveEnvironment( this.api.environments?.getActiveEnvironmentPath( - this.workspaceFolderManager.activeWorkspaceFolder + this.workspaceFolderManager.activeProjectUri ) ); } diff --git a/packages/databricks-vscode/src/language/notebooks/DatabricksNbCellLimits.ts b/packages/databricks-vscode/src/language/notebooks/DatabricksNbCellLimits.ts index ca200a534..03189b17c 100644 --- a/packages/databricks-vscode/src/language/notebooks/DatabricksNbCellLimits.ts +++ b/packages/databricks-vscode/src/language/notebooks/DatabricksNbCellLimits.ts @@ -8,7 +8,7 @@ export async function setDbnbCellLimits( connectionManager: ConnectionManager ) { await FileUtils.waitForDatabricksProject( - workspaceFolderManager.activeWorkspaceFolder.uri, + workspaceFolderManager.activeProjectUri, connectionManager ); if (workspaceConfigs.jupyterCellMarkerRegex === undefined) { diff --git a/packages/databricks-vscode/src/language/notebooks/NotebookInitScriptManager.ts b/packages/databricks-vscode/src/language/notebooks/NotebookInitScriptManager.ts index 62c494c55..2cdac1403 100644 --- a/packages/databricks-vscode/src/language/notebooks/NotebookInitScriptManager.ts +++ b/packages/databricks-vscode/src/language/notebooks/NotebookInitScriptManager.ts @@ -233,8 +233,7 @@ export class NotebookInitScriptManager implements Disposable { ["-m", "IPython", file], { env, - cwd: this.workspaceFolderManager.activeWorkspaceFolder.uri - .fsPath, + cwd: this.workspaceFolderManager.activeProjectUri.fsPath, } ); const correctlyFormatttedErrors = stderr @@ -292,7 +291,7 @@ export class NotebookInitScriptManager implements Disposable { } await FileUtils.waitForDatabricksProject( - this.workspaceFolderManager.activeWorkspaceFolder.uri, + this.workspaceFolderManager.activeProjectUri, this.connectionManager ); diff --git a/packages/databricks-vscode/src/test/e2e/bundle_sub_folder.e2e.ts b/packages/databricks-vscode/src/test/e2e/bundle_sub_folder.e2e.ts new file mode 100644 index 000000000..fee2f9eb2 --- /dev/null +++ b/packages/databricks-vscode/src/test/e2e/bundle_sub_folder.e2e.ts @@ -0,0 +1,127 @@ +import path from "node:path"; +import * as fs from "fs/promises"; +import assert from "node:assert"; +import { + dismissNotifications, + getUniqueResourceName, + getViewSection, + waitForInput, + waitForLogin, +} from "./utils/commonUtils.ts"; +import {createProjectWithJob} from "./utils/dabsFixtures.ts"; +import {CustomTreeSection} from "wdio-vscode-service"; +import {getResourceViewItem} from "./utils/dabsExplorerUtils.ts"; + +async function getLocalFolderTreeItem(folder: string) { + const section = (await getViewSection( + "CONFIGURATION" + )) as CustomTreeSection; + assert(section, "CONFIGURATION section doesn't exist"); + const items = await section.getVisibleItems(); + for (const item of items) { + const label = await item.getLabel(); + if (label.toLowerCase().includes("local folder")) { + const desc = await item.getDescription(); + const descPath = path.normalize(desc!); + console.log("Local Folder description:", descPath); + if (descPath.includes(folder)) { + return item; + } + } + } + return undefined; +} + +describe("Bundle in a sub folder", async function () { + this.timeout(3 * 60 * 1000); + + const folders = ["nested1", path.normalize("double/nested2")]; + const jobs = {}; + + before(async () => { + assert(process.env.WORKSPACE_PATH, "WORKSPACE_PATH doesn't exist"); + for (const dir of folders) { + const projectDir = path.join(process.env.WORKSPACE_PATH!, dir); + await fs.mkdir(projectDir, {recursive: true}); + const job = await createProjectWithJob( + getUniqueResourceName(dir), + projectDir, + process.env.TEST_DEFAULT_CLUSTER_ID! + ); + jobs[dir] = job; + } + }); + + for (const folder of folders) { + it(`should select the sub folder ${folder} with a project`, async () => { + if (folder === "nested1") { + console.log( + "Selecting Databricks Project Folder through the welcome screen UI" + ); + const section = await getViewSection("CONFIGURATION"); + const selectProjectButton = await browser.waitUntil( + async () => { + const welcome = await section!.findWelcomeContent(); + const buttons = await welcome!.getButtons(); + for (const button of buttons) { + const title = await button.getTitle(); + if (title === "Select a project") { + return button; + } + } + } + ); + assert( + selectProjectButton, + "'Select a project' button doesn't exist" + ); + await selectProjectButton.elem.click(); + } else { + console.log( + "Selecting Databricks Project Folder though a tree item command" + ); + await dismissNotifications(); + const localFolderItem = await getLocalFolderTreeItem("nested1"); + await localFolderItem!.select(); + } + + const subFoldersInput = await waitForInput(); + assert.ok( + await subFoldersInput.findQuickPick(folder), + `Quick pick for ${folder} not found` + ); + await subFoldersInput.selectQuickPick(folder); + + await waitForLogin("DEFAULT"); + }); + + it(`should show a Local Folder configuration item for ${folder}`, async () => { + const localFolderItem = await getLocalFolderTreeItem(folder); + assert.ok( + localFolderItem, + "Local Folder configuration item not found" + ); + }); + + it(`should show a job in the resource explorer for ${folder}`, async () => { + const resourceExplorerView = (await getViewSection( + "BUNDLE RESOURCE EXPLORER" + )) as CustomTreeSection; + await browser.waitUntil( + async () => { + const job = await getResourceViewItem( + resourceExplorerView, + "Workflows", + jobs[folder].name! + ); + return job !== undefined; + }, + { + timeout: 20_000, + interval: 2_000, + timeoutMsg: `Job view item with name ${jobs[folder].name} not found`, + } + ); + }); + } +}); diff --git a/packages/databricks-vscode/src/test/e2e/deploy_and_run_job.e2e.ts b/packages/databricks-vscode/src/test/e2e/deploy_and_run_job.e2e.ts index 8a6e95b0e..90353dc28 100644 --- a/packages/databricks-vscode/src/test/e2e/deploy_and_run_job.e2e.ts +++ b/packages/databricks-vscode/src/test/e2e/deploy_and_run_job.e2e.ts @@ -1,6 +1,7 @@ import assert from "node:assert"; import { dismissNotifications, + getUniqueResourceName, getViewSection, waitForDeployment, waitForLogin, @@ -35,10 +36,13 @@ describe("Deploy and run job", async function () { ); workbench = await browser.getWorkbench(); - jobName = await createProjectWithJob( - process.env.WORKSPACE_PATH, - process.env.TEST_DEFAULT_CLUSTER_ID - ); + jobName = ( + await createProjectWithJob( + getUniqueResourceName("deploy_and_run_job"), + process.env.WORKSPACE_PATH, + process.env.TEST_DEFAULT_CLUSTER_ID + ) + ).name!; await dismissNotifications(); }); diff --git a/packages/databricks-vscode/src/test/e2e/destroy.e2e.ts b/packages/databricks-vscode/src/test/e2e/destroy.e2e.ts index 67c51fa38..cccf338e6 100644 --- a/packages/databricks-vscode/src/test/e2e/destroy.e2e.ts +++ b/packages/databricks-vscode/src/test/e2e/destroy.e2e.ts @@ -7,21 +7,8 @@ import { waitForTreeItems, } from "./utils/commonUtils.ts"; import {Workbench} from "wdio-vscode-service"; -import { - getBasicBundleConfig, - getSimpleJobsResource, - writeRootBundleConfig, -} from "./utils/dabsFixtures.ts"; -import path from "node:path"; -import fs from "fs/promises"; -import {BundleSchema} from "../../bundle/types.ts"; -import {fileURLToPath} from "url"; import {WorkspaceClient} from "@databricks/databricks-sdk"; - -/* eslint-disable @typescript-eslint/naming-convention */ -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -/* eslint-enable @typescript-eslint/naming-convention */ +import {createProjectWithJob} from "./utils/dabsFixtures.ts"; describe("Deploy and destroy", async function () { let workbench: Workbench; @@ -31,58 +18,6 @@ describe("Deploy and destroy", async function () { this.timeout(3 * 60 * 1000); - async function createProjectWithJob() { - /** - * process.env.WORKSPACE_PATH (cwd) - * ├── databricks.yml - * └── src - * └── notebook.ipynb - */ - - const projectName = getUniqueResourceName("deploy_and_destroy_bundle"); - const notebookTaskName = getUniqueResourceName("notebook_task"); - /* eslint-disable @typescript-eslint/naming-convention */ - const jobDef = getSimpleJobsResource({ - tasks: [ - { - task_key: notebookTaskName, - notebook_task: { - notebook_path: "src/notebook.ipynb", - }, - existing_cluster_id: clusterId, - }, - ], - }); - jobName = jobDef.name!; - - const schemaDef: BundleSchema = getBasicBundleConfig({ - bundle: { - name: projectName, - deployment: {}, - }, - targets: { - dev_test: { - resources: { - jobs: { - vscode_integration_test: jobDef, - }, - }, - }, - }, - }); - /* eslint-enable @typescript-eslint/naming-convention */ - - await writeRootBundleConfig(schemaDef, vscodeWorkspaceRoot); - - await fs.mkdir(path.join(vscodeWorkspaceRoot, "src"), { - recursive: true, - }); - await fs.copyFile( - path.join(__dirname, "resources", "spark_select_1.ipynb"), - path.join(vscodeWorkspaceRoot, "src", "notebook.ipynb") - ); - } - before(async function () { assert( process.env.TEST_DEFAULT_CLUSTER_ID, @@ -100,7 +35,12 @@ describe("Deploy and destroy", async function () { clusterId = process.env.TEST_DEFAULT_CLUSTER_ID; workbench = await browser.getWorkbench(); vscodeWorkspaceRoot = process.env.WORKSPACE_PATH; - await createProjectWithJob(); + const job = await createProjectWithJob( + getUniqueResourceName("deploy_and_destroy_bundle"), + vscodeWorkspaceRoot, + clusterId + ); + jobName = job.name!; await dismissNotifications(); }); diff --git a/packages/databricks-vscode/src/test/e2e/refresh_resource_explorer_on_yml_change.e2e.ts b/packages/databricks-vscode/src/test/e2e/refresh_resource_explorer_on_yml_change.e2e.ts index 8694e324a..9d053a0a5 100644 --- a/packages/databricks-vscode/src/test/e2e/refresh_resource_explorer_on_yml_change.e2e.ts +++ b/packages/databricks-vscode/src/test/e2e/refresh_resource_explorer_on_yml_change.e2e.ts @@ -2,88 +2,27 @@ import assert from "assert"; import {CustomTreeSection} from "wdio-vscode-service"; import { dismissNotifications, - getUniqueResourceName, getViewSection, waitForLogin, } from "./utils/commonUtils.ts"; -import fs from "fs/promises"; -import path from "path"; import { + createProjectWithJob, getBasicBundleConfig, - getSimpleJobsResource, writeRootBundleConfig, } from "./utils/dabsFixtures.ts"; -import {BundleSchema, BundleTarget, Resource} from "../../bundle/types"; import { geTaskViewItem, getResourceViewItem, } from "./utils/dabsExplorerUtils.ts"; -import {fileURLToPath} from "url"; - -/* eslint-disable @typescript-eslint/naming-convention */ -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -/* eslint-enable @typescript-eslint/naming-convention */ describe("Automatically refresh resource explorer", async function () { let vscodeWorkspaceRoot: string; let projectName: string; let resourceExplorerView: CustomTreeSection; let clusterId: string; - let jobDef: Resource; this.timeout(3 * 60 * 1000); - async function createProjectWithJob() { - /** - * process.env.WORKSPACE_PATH (cwd) - * ├── databricks.yml - * └── src - * └── notebook.ipynb - */ - - const notebookTaskName = getUniqueResourceName("notebook_task"); - /* eslint-disable @typescript-eslint/naming-convention */ - jobDef = getSimpleJobsResource({ - tasks: [ - { - task_key: notebookTaskName, - notebook_task: { - notebook_path: "src/notebook.ipynb", - }, - existing_cluster_id: clusterId, - }, - ], - }); - - const schemaDef: BundleSchema = getBasicBundleConfig({ - bundle: { - name: projectName, - deployment: {}, - }, - targets: { - dev_test: { - resources: { - jobs: { - vscode_integration_test: jobDef, - }, - }, - }, - }, - }); - /* eslint-enable @typescript-eslint/naming-convention */ - - await writeRootBundleConfig(schemaDef, vscodeWorkspaceRoot); - - await fs.mkdir(path.join(vscodeWorkspaceRoot, "src"), { - recursive: true, - }); - await fs.copyFile( - path.join(__dirname, "resources", "spark_select_1.ipynb"), - path.join(vscodeWorkspaceRoot, "src", "notebook.ipynb") - ); - } - before(async function () { assert( process.env.TEST_DEFAULT_CLUSTER_ID, @@ -141,7 +80,11 @@ describe("Automatically refresh resource explorer", async function () { .openOutputView(); await outputView.selectChannel("Databricks Bundle Logs"); - await createProjectWithJob(); + const jobDef = await createProjectWithJob( + projectName, + vscodeWorkspaceRoot, + clusterId + ); await browser.waitUntil( async () => { diff --git a/packages/databricks-vscode/src/test/e2e/utils/commonUtils.ts b/packages/databricks-vscode/src/test/e2e/utils/commonUtils.ts index e08bf519d..b5b4e1413 100644 --- a/packages/databricks-vscode/src/test/e2e/utils/commonUtils.ts +++ b/packages/databricks-vscode/src/test/e2e/utils/commonUtils.ts @@ -146,7 +146,7 @@ export async function waitForSyncComplete() { await browser.waitUntil( async () => { - const subTreeItems = await viewSection.openItem("Workspace Folder"); + const subTreeItems = await viewSection.openItem("Remote Folder"); for (const item of subTreeItems) { if ((await item.getLabel()).includes("State")) { const status = await item.getDescription(); @@ -319,6 +319,7 @@ export async function executeCommandWhenAvailable(command: string) { await workbench.executeQuickPick(command); return true; } catch (e) { + console.log(`Failed to execute ${command}:`, e); return false; } }); diff --git a/packages/databricks-vscode/src/test/e2e/utils/dabsFixtures.ts b/packages/databricks-vscode/src/test/e2e/utils/dabsFixtures.ts index a1542f64c..7c87e993a 100644 --- a/packages/databricks-vscode/src/test/e2e/utils/dabsFixtures.ts +++ b/packages/databricks-vscode/src/test/e2e/utils/dabsFixtures.ts @@ -101,6 +101,7 @@ export async function clearRootBundleConfig(workspacePath: string) { } export async function createProjectWithJob( + projectName: string, vscodeWorkspaceRoot: string, clusterId: string ) { @@ -111,7 +112,6 @@ export async function createProjectWithJob( * └── notebook.ipynb */ - const projectName = getUniqueResourceName("deploy_and_run_job"); const notebookTaskName = getUniqueResourceName("notebook_task"); /* eslint-disable @typescript-eslint/naming-convention */ const jobDef = getSimpleJobsResource({ @@ -152,7 +152,7 @@ export async function createProjectWithJob( path.join(vscodeWorkspaceRoot, "src", "notebook.ipynb") ); - return jobDef!.name!; + return jobDef; } export async function createProjectWithPipeline(vscodeWorkspaceRoot: string) { diff --git a/packages/databricks-vscode/src/test/runTest.ts b/packages/databricks-vscode/src/test/runTest.ts index 5ab5a98fe..fd3b4a55a 100644 --- a/packages/databricks-vscode/src/test/runTest.ts +++ b/packages/databricks-vscode/src/test/runTest.ts @@ -23,12 +23,14 @@ async function main() { cachePath, }); + const tmpDir = os.tmpdir(); + // Download VS Code, unzip it and run the integration test await runTests({ vscodeExecutablePath, extensionDevelopmentPath, extensionTestsPath, - launchArgs: ["--user-data-dir", `${os.tmpdir()}`], + launchArgs: [tmpDir, "--user-data-dir", tmpDir], extensionTestsEnv: { [EXTENSION_DEVELOPMENT]: "true", }, diff --git a/packages/databricks-vscode/src/ui/configuration-view/SyncDestinationComponent.ts b/packages/databricks-vscode/src/ui/configuration-view/SyncDestinationComponent.ts index 22fe523bb..8dd409f70 100644 --- a/packages/databricks-vscode/src/ui/configuration-view/SyncDestinationComponent.ts +++ b/packages/databricks-vscode/src/ui/configuration-view/SyncDestinationComponent.ts @@ -92,7 +92,7 @@ export class SyncDestinationComponent extends BaseComponent { return [ { - label: "Workspace Folder", + label: "Remote Folder", tooltip: url ? undefined : "Created after deploy", description: mode === "development" ? undefined : workspaceFsPath, @@ -145,6 +145,7 @@ export class SyncDestinationComponent extends BaseComponent { return [ pathTreeItem, { + label: `Sync State`, description: "Error - Click for more details", iconPath: new ThemeIcon( "alert", @@ -170,7 +171,7 @@ export class SyncDestinationComponent extends BaseComponent { return [ pathTreeItem, { - label: `State`, + label: `Sync State`, description: this.codeSynchronizer.state, collapsibleState: TreeItemCollapsibleState.None, }, diff --git a/packages/databricks-vscode/src/ui/configuration-view/WorkspaceFolderComponent.ts b/packages/databricks-vscode/src/ui/configuration-view/WorkspaceFolderComponent.ts index b05af2a50..5b3fdb512 100644 --- a/packages/databricks-vscode/src/ui/configuration-view/WorkspaceFolderComponent.ts +++ b/packages/databricks-vscode/src/ui/configuration-view/WorkspaceFolderComponent.ts @@ -1,6 +1,6 @@ import {BaseComponent} from "./BaseComponent"; import {ConfigurationTreeItem} from "./types"; -import {ThemeIcon} from "vscode"; +import {ThemeIcon, workspace} from "vscode"; import {WorkspaceFolderManager} from "../../vscode-objs/WorkspaceFolderManager"; export class WorkspaceFolderComponent extends BaseComponent { @@ -9,7 +9,7 @@ export class WorkspaceFolderComponent extends BaseComponent { ) { super(); this.disposables.push( - this.workspaceFolderManager.onDidChangeActiveWorkspaceFolder(() => { + this.workspaceFolderManager.onDidChangeActiveProjectFolder(() => { this.onDidChangeEmitter.fire(); }) ); @@ -17,23 +17,21 @@ export class WorkspaceFolderComponent extends BaseComponent { private async getRoot(): Promise { const activeWorkspaceFolder = - this.workspaceFolderManager.activeWorkspaceFolder; - if ( - activeWorkspaceFolder === undefined || - !this.workspaceFolderManager.enableUi - ) { + this.workspaceFolderManager.activeProjectUri; + + if (activeWorkspaceFolder === undefined) { return []; } return [ { - label: "Active Workspace Folder", + label: "Local Folder", iconPath: new ThemeIcon("folder"), - description: activeWorkspaceFolder.name, - contextValue: "databricks.configuration.activeWorkspaceFolder", + description: workspace.asRelativePath(activeWorkspaceFolder), + contextValue: "databricks.configuration.activeProjectFolder", command: { - title: "Select Workspace Folder", - command: "databricks.selectWorkspaceFolder", + title: "Select Active Project Folder", + command: "databricks.bundle.selectActiveProjectFolder", }, }, ]; diff --git a/packages/databricks-vscode/src/utils/envVarGenerators.ts b/packages/databricks-vscode/src/utils/envVarGenerators.ts index 3160e0030..5c2db0aa6 100644 --- a/packages/databricks-vscode/src/utils/envVarGenerators.ts +++ b/packages/databricks-vscode/src/utils/envVarGenerators.ts @@ -111,7 +111,7 @@ async function getSparkRemoteEnvVar(connectionManager: ConnectionManager) { export async function getDbConnectEnvVars( connectionManager: ConnectionManager, - workspacePath: Uri, + projectRootUri: Uri, showDatabricksConnectProgess: boolean ) { const userAgent = getUserAgent(connectionManager); @@ -125,7 +125,7 @@ export async function getDbConnectEnvVars( SPARK_CONNECT_PROGRESS_BAR_ENABLED: showDatabricksConnectProgess ? "1" : "0", - DATABRICKS_PROJECT_ROOT: workspacePath.fsPath, + DATABRICKS_PROJECT_ROOT: projectRootUri.fsPath, ...((await getSparkRemoteEnvVar(connectionManager)) || {}), }; /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/packages/databricks-vscode/src/vscode-objs/StateStorage.ts b/packages/databricks-vscode/src/vscode-objs/StateStorage.ts index d003ad281..3c716599b 100644 --- a/packages/databricks-vscode/src/vscode-objs/StateStorage.ts +++ b/packages/databricks-vscode/src/vscode-objs/StateStorage.ts @@ -62,6 +62,10 @@ const StorageConfigurations = { location: "workspace", }), + "databricks.activeProjectPath": withType()({ + location: "workspace", + }), + "databricks.lastInstalledExtensionVersion": withType()({ location: "global", defaultValue: "0.0.0", diff --git a/packages/databricks-vscode/src/vscode-objs/WorkspaceFolderManager.test.ts b/packages/databricks-vscode/src/vscode-objs/WorkspaceFolderManager.test.ts new file mode 100644 index 000000000..a49f89738 --- /dev/null +++ b/packages/databricks-vscode/src/vscode-objs/WorkspaceFolderManager.test.ts @@ -0,0 +1,70 @@ +import {workspace} from "vscode"; +import {CustomWhenContext} from "./CustomWhenContext"; +import {StateStorage} from "./StateStorage"; +import {WorkspaceFolderManager} from "./WorkspaceFolderManager"; +import {instance, mock, when} from "ts-mockito"; +import assert from "node:assert"; +import path from "node:path"; + +describe(__filename, () => { + it("should correctly set workspace and project folders", () => { + const stateStorage = mock(); + const workspaceFolder = workspace.workspaceFolders?.[0]; + assert.ok(workspaceFolder, "workspaceFolder is not defined"); + const workspaceFolderManager = new WorkspaceFolderManager( + new CustomWhenContext(), + instance(stateStorage) + ); + assert.strictEqual( + workspaceFolderManager.activeProjectUri, + workspaceFolder.uri + ); + assert.strictEqual( + workspaceFolderManager.activeWorkspaceFolder.uri, + workspaceFolder.uri + ); + }); + + it("should correctly set workspace and project folders based on the state storage", () => { + const stateStorage = mock(); + const workspaceFolder = workspace.workspaceFolders?.[0]; + assert.ok(workspaceFolder, "workspaceFolder is not defined"); + const projectPath = path.join(workspaceFolder.uri.fsPath, "project"); + when(stateStorage.get("databricks.activeProjectPath")).thenReturn( + projectPath + ); + const workspaceFolderManager = new WorkspaceFolderManager( + new CustomWhenContext(), + instance(stateStorage) + ); + assert.strictEqual( + workspaceFolderManager.activeProjectUri.fsPath, + projectPath + ); + assert.strictEqual( + workspaceFolderManager.activeWorkspaceFolder.uri, + workspaceFolder.uri + ); + }); + + it("should fallback to default workspace and project folders if the state storage path is outside of the workspace", () => { + const stateStorage = mock(); + const workspaceFolder = workspace.workspaceFolders?.[0]; + assert.ok(workspaceFolder, "workspaceFolder is not defined"); + when(stateStorage.get("databricks.activeProjectPath")).thenReturn( + "/hello" + ); + const workspaceFolderManager = new WorkspaceFolderManager( + new CustomWhenContext(), + instance(stateStorage) + ); + assert.strictEqual( + workspaceFolderManager.activeProjectUri, + workspaceFolder.uri + ); + assert.strictEqual( + workspaceFolderManager.activeWorkspaceFolder.uri, + workspaceFolder.uri + ); + }); +}); diff --git a/packages/databricks-vscode/src/vscode-objs/WorkspaceFolderManager.ts b/packages/databricks-vscode/src/vscode-objs/WorkspaceFolderManager.ts index b9c68ef15..77cf961b4 100644 --- a/packages/databricks-vscode/src/vscode-objs/WorkspaceFolderManager.ts +++ b/packages/databricks-vscode/src/vscode-objs/WorkspaceFolderManager.ts @@ -1,44 +1,52 @@ import { Disposable, EventEmitter, - QuickPickItem, - QuickPickItemKind, - StatusBarAlignment, TextEditor, + Uri, WorkspaceFolder, window, workspace, } from "vscode"; import {CustomWhenContext} from "./CustomWhenContext"; +import {StateStorage} from "./StateStorage"; +import {NamedLogger} from "@databricks/databricks-sdk/dist/logging"; +import {Loggers} from "../logger"; export class WorkspaceFolderManager implements Disposable { + private logger = NamedLogger.getOrCreate(Loggers.Extension); private disposables: Disposable[] = []; private _activeWorkspaceFolder: WorkspaceFolder | undefined = workspace.workspaceFolders?.[0]; - private readonly didChangeActiveWorkspaceFolder = new EventEmitter< - WorkspaceFolder | undefined + private _activeProjectUri: Uri | undefined = + workspace.workspaceFolders?.[0]?.uri; + private readonly didChangeActiveProjectFolder = new EventEmitter< + Uri | undefined >(); - public readonly onDidChangeActiveWorkspaceFolder = - this.didChangeActiveWorkspaceFolder.event; - - private readonly button = window.createStatusBarItem( - StatusBarAlignment.Left, - 999 - ); - - constructor(public readonly customWhenContext: CustomWhenContext) { - if (this.enableUi) { - this.button.text = - this._activeWorkspaceFolder?.name ?? "No Databricks Project"; - this.button.tooltip = "Selected databricks project"; - this.button.command = "databricks.selectWorkspaceFolder"; - this.button.show(); + public readonly onDidChangeActiveProjectFolder = + this.didChangeActiveProjectFolder.event; + + constructor( + private readonly customWhenContext: CustomWhenContext, + private readonly stateStorage: StateStorage + ) { + const activeProjectPath = this.stateStorage.get( + "databricks.activeProjectPath" + ); + if (activeProjectPath) { + const uri = Uri.file(activeProjectPath); + const folder = workspace.getWorkspaceFolder(uri); + if (folder) { + this._activeProjectUri = uri; + this._activeWorkspaceFolder = folder; + } } - - this.setIsActiveFileInActiveWorkspace(window.activeTextEditor); + this.logger.log("Active project:", this._activeProjectUri?.fsPath); + this.logger.log( + "Active workspace:", + this._activeWorkspaceFolder?.uri.fsPath + ); this.disposables.push( - this.button, workspace.onDidChangeWorkspaceFolders((e) => { if ( e.removed.find( @@ -48,100 +56,70 @@ export class WorkspaceFolderManager implements Disposable { ) || this._activeWorkspaceFolder === undefined ) { - this.setActiveWorkspaceFolder( - workspace.workspaceFolders?.[0] - ); - return; + const folder = workspace.workspaceFolders?.[0]; + if (folder) { + this.setActiveProjectFolder(folder.uri, folder); + } } }), window.onDidChangeActiveTextEditor((editor) => { - this.setIsActiveFileInActiveWorkspace(editor); + this.setIsActiveFileInActiveProject(editor); }), - this.onDidChangeActiveWorkspaceFolder(() => { - this.setIsActiveFileInActiveWorkspace(window.activeTextEditor); + this.onDidChangeActiveProjectFolder(() => { + this.setIsActiveFileInActiveProject(window.activeTextEditor); }) ); + + this.setIsActiveFileInActiveProject(window.activeTextEditor); } - private setIsActiveFileInActiveWorkspace(activeEditor?: TextEditor) { + private setIsActiveFileInActiveProject(activeEditor?: TextEditor) { const isActiveFileInActiveWorkspace = - this.activeWorkspaceFolder !== undefined && + this.activeProjectUri !== undefined && activeEditor !== undefined && activeEditor.document.uri.fsPath.startsWith( - this.activeWorkspaceFolder?.uri.fsPath + this.activeProjectUri?.fsPath ); this.customWhenContext.setIsActiveFileInActiveWorkspace( isActiveFileInActiveWorkspace ); } - get activeWorkspaceFolder() { - if (this._activeWorkspaceFolder === undefined) { - throw new Error("No active workspace folder"); + get activeProjectUri() { + if (this._activeProjectUri === undefined) { + throw new Error("No active project folder"); } - return this._activeWorkspaceFolder; + return this._activeProjectUri; } - setActiveWorkspaceFolder(folder?: WorkspaceFolder) { - if (this._activeWorkspaceFolder?.uri.fsPath === folder?.uri.fsPath) { - return; - } - - this._activeWorkspaceFolder = folder; - this.didChangeActiveWorkspaceFolder.fire(folder); - - if (this.enableUi) { - this.button.text = folder?.name ?? "No Databricks Project"; - this.button.show(); + get activeWorkspaceFolder() { + if (this._activeWorkspaceFolder === undefined) { + throw new Error("No active workspace folder"); } - } - get folders() { - return workspace.workspaceFolders; + return this._activeWorkspaceFolder; } - async selectDatabricksWorkspaceFolderCommand() { - const items: (QuickPickItem & { - workspaceFolder: WorkspaceFolder; - })[] = - this.folders - ?.filter((i) => i.name !== this.activeWorkspaceFolder.name) - .map((folder) => ({ - label: folder.name, - description: folder.uri.fsPath, - workspaceFolder: folder, - })) ?? []; - - const firstItem = this.activeWorkspaceFolder - ? [ - { - label: "Selected Databricks Workspace Folder", - kind: QuickPickItemKind.Separator, - }, - { - label: this.activeWorkspaceFolder.name, - description: this.activeWorkspaceFolder.uri.fsPath, - workspaceFolder: this.activeWorkspaceFolder, - }, - { - label: "", - kind: QuickPickItemKind.Separator, - }, - ] - : []; - - const choice = await window.showQuickPick([...firstItem, ...items], { - title: "Select Databricks Workspace Folder", - }); - if (!choice) { + setActiveProjectFolder( + projectFolder: Uri, + workspaceFolder: WorkspaceFolder + ) { + if ( + this._activeProjectUri?.fsPath === projectFolder?.fsPath && + this._activeWorkspaceFolder?.uri.fsPath === + workspaceFolder.uri.fsPath + ) { return; } - this.setActiveWorkspaceFolder(choice.workspaceFolder); - } - get enableUi() { - return this.folders && this.folders?.length > 1; + this._activeWorkspaceFolder = workspaceFolder; + this._activeProjectUri = projectFolder; + this.stateStorage.set( + "databricks.activeProjectPath", + projectFolder.fsPath + ); + this.didChangeActiveProjectFolder.fire(projectFolder); } dispose() { diff --git a/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.ts b/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.ts index b829664fe..27029a6c0 100644 --- a/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.ts +++ b/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.ts @@ -76,7 +76,7 @@ export class WorkspaceFsCommands implements Disposable { const root = await this.getValidRoot(rootPath, ctx); const inputPath = await createDirWizard( - this.workspaceFolderManager.activeWorkspaceFolder.uri, + this.workspaceFolderManager.activeProjectUri, "Directory Name", root );