From b54bbc9363abf7a252fb48cc6d3a66b37de4e09c Mon Sep 17 00:00:00 2001 From: misha-db Date: Mon, 13 Apr 2026 16:49:56 +0400 Subject: [PATCH 1/8] WSFS explorer --- packages/databricks-vscode/package.json | 88 ++++++++++- packages/databricks-vscode/src/extension.ts | 40 ++++- .../sdk-extensions/wsfs/WorkspaceFsEntity.ts | 8 + .../sdk-extensions/wsfs/WorkspaceFsFile.ts | 8 + .../src/vscode-objs/CustomWhenContext.ts | 10 -- .../src/workspace-fs/WorkspaceFsCommands.ts | 124 +++++++++++++++- .../workspace-fs/WorkspaceFsDataProvider.ts | 21 ++- .../WorkspaceFsFileSystemProvider.ts | 139 ++++++++++++++++++ .../src/workspace-fs/index.ts | 1 + 9 files changed, 421 insertions(+), 18 deletions(-) create mode 100644 packages/databricks-vscode/src/workspace-fs/WorkspaceFsFileSystemProvider.ts diff --git a/packages/databricks-vscode/package.json b/packages/databricks-vscode/package.json index f31081e57..02ab2fa30 100644 --- a/packages/databricks-vscode/package.json +++ b/packages/databricks-vscode/package.json @@ -189,6 +189,36 @@ "enablement": "databricks.context.activated && databricks.context.loggedIn && databricks.feature.views.workspace && !databricks.context.remoteMode", "category": "Databricks" }, + { + "command": "databricks.wsfs.openInBrowser", + "title": "Open in Browser", + "icon": "$(link-external)", + "category": "Databricks" + }, + { + "command": "databricks.wsfs.copyPath", + "title": "Copy Path", + "icon": "$(copy)", + "category": "Databricks" + }, + { + "command": "databricks.wsfs.delete", + "title": "Delete", + "icon": "$(trash)", + "category": "Databricks" + }, + { + "command": "databricks.wsfs.uploadFile", + "title": "Upload File", + "icon": "$(cloud-upload)", + "category": "Databricks" + }, + { + "command": "databricks.wsfs.downloadFile", + "title": "Download", + "icon": "$(cloud-download)", + "category": "Databricks" + }, { "command": "databricks.call", "title": "Call", @@ -210,7 +240,6 @@ "icon": "$(gear)", "title": "Select a Declarative Automation Bundle target", "enablement": "databricks.context.activated && !databricks.context.remoteMode", - "category": "Databricks" }, { @@ -451,7 +480,7 @@ { "id": "workspaceFsView", "name": "Workspace explorer", - "when": "databricks.feature.views.workspace && !databricks.context.remoteMode" + "when": "!databricks.context.remoteMode" }, { "id": "databricksDocsView", @@ -537,6 +566,11 @@ "when": "view == workspaceFsView", "group": "navigation@1" }, + { + "command": "databricks.wsfs.uploadFile", + "when": "view == workspaceFsView", + "group": "navigation@1" + }, { "command": "databricks.bundle.refreshRemoteState", "when": "view == dabsResourceExplorerView && databricks.context.bundle.deploymentState == idle", @@ -583,6 +617,36 @@ } ], "view/item/context": [ + { + "command": "databricks.wsfs.copyPath", + "when": "view == workspaceFsView && viewItem =~ /^wsfs\\./", + "group": "wsfs_nav@0" + }, + { + "command": "databricks.wsfs.openInBrowser", + "when": "view == workspaceFsView && viewItem =~ /^wsfs\\./", + "group": "wsfs_nav@1" + }, + { + "command": "databricks.wsfs.createFolder", + "when": "view == workspaceFsView && (viewItem == wsfs.directory || viewItem == wsfs.repo)", + "group": "wsfs_mut@0" + }, + { + "command": "databricks.wsfs.uploadFile", + "when": "view == workspaceFsView && (viewItem == wsfs.directory || viewItem == wsfs.repo)", + "group": "wsfs_mut@1" + }, + { + "command": "databricks.wsfs.downloadFile", + "when": "view == workspaceFsView && (viewItem == wsfs.file || viewItem == wsfs.notebook)", + "group": "wsfs_mut@0" + }, + { + "command": "databricks.wsfs.delete", + "when": "view == workspaceFsView && viewItem =~ /^wsfs\\./", + "group": "wsfs_danger@0" + }, { "command": "databricks.utils.openExternal", "when": "viewItem =~ /^databricks.*\\.(has-url).*$/ && databricks.context.bundle.deploymentState == idle", @@ -841,6 +905,26 @@ "command": "databricks.wsfs.refresh", "when": "config.databricks.sync.destinationType == workspace" }, + { + "command": "databricks.wsfs.delete", + "when": "false" + }, + { + "command": "databricks.wsfs.downloadFile", + "when": "false" + }, + { + "command": "databricks.wsfs.copyPath", + "when": "false" + }, + { + "command": "databricks.wsfs.openInBrowser", + "when": "false" + }, + { + "command": "databricks.wsfs.uploadFile", + "when": "false" + }, { "command": "databricks.utils.openExternal", "when": "false" diff --git a/packages/databricks-vscode/src/extension.ts b/packages/databricks-vscode/src/extension.ts index a99488748..6ad74a9f3 100644 --- a/packages/databricks-vscode/src/extension.ts +++ b/packages/databricks-vscode/src/extension.ts @@ -29,7 +29,11 @@ import { UtilsCommands, } from "./utils"; import {ConfigureAutocomplete} from "./language/ConfigureAutocomplete"; -import {WorkspaceFsCommands, WorkspaceFsDataProvider} from "./workspace-fs"; +import { + WorkspaceFsCommands, + WorkspaceFsDataProvider, + WorkspaceFsFileSystemProvider, +} from "./workspace-fs"; import {CustomWhenContext} from "./vscode-objs/CustomWhenContext"; import {StateStorage} from "./vscode-objs/StateStorage"; import path from "node:path"; @@ -265,7 +269,6 @@ export async function activate( // manage contexts for experimental features function updateFeatureContexts() { customWhenContext.updateShowClusterView(); - customWhenContext.updateShowWorkspaceView(); } function updateStrictSSLEnv() { @@ -382,13 +385,19 @@ export async function activate( const workspaceFsDataProvider = new WorkspaceFsDataProvider( connectionManager ); + const workspaceFsFsp = new WorkspaceFsFileSystemProvider(connectionManager); const workspaceFsCommands = new WorkspaceFsCommands( workspaceFolderManager, connectionManager, - workspaceFsDataProvider + workspaceFsDataProvider, + workspaceFsFsp ); context.subscriptions.push( + workspace.registerFileSystemProvider("wsfs", workspaceFsFsp, { + isCaseSensitive: true, + }), + workspaceFsFsp, window.registerTreeDataProvider( "workspaceFsView", workspaceFsDataProvider @@ -402,6 +411,31 @@ export async function activate( "databricks.wsfs.createFolder", workspaceFsCommands.createFolder, workspaceFsCommands + ), + telemetry.registerCommand( + "databricks.wsfs.openInBrowser", + workspaceFsCommands.openInBrowser, + workspaceFsCommands + ), + telemetry.registerCommand( + "databricks.wsfs.copyPath", + workspaceFsCommands.copyPath, + workspaceFsCommands + ), + telemetry.registerCommand( + "databricks.wsfs.delete", + workspaceFsCommands.deleteItem, + workspaceFsCommands + ), + telemetry.registerCommand( + "databricks.wsfs.uploadFile", + workspaceFsCommands.uploadFile, + workspaceFsCommands + ), + telemetry.registerCommand( + "databricks.wsfs.downloadFile", + workspaceFsCommands.downloadFile, + workspaceFsCommands ) ); diff --git a/packages/databricks-vscode/src/sdk-extensions/wsfs/WorkspaceFsEntity.ts b/packages/databricks-vscode/src/sdk-extensions/wsfs/WorkspaceFsEntity.ts index ee024e8de..ab8f82c60 100644 --- a/packages/databricks-vscode/src/sdk-extensions/wsfs/WorkspaceFsEntity.ts +++ b/packages/databricks-vscode/src/sdk-extensions/wsfs/WorkspaceFsEntity.ts @@ -175,6 +175,14 @@ export abstract class WorkspaceFsEntity { get basename(): string { return posix.basename(this.path); } + + @withLogContext(ExposedLoggers.SDK) + async delete(recursive = false, @context ctx?: Context): Promise { + await this._workspaceFsService.delete( + {path: this.path, recursive}, + ctx + ); + } } async function entityFromObjInfo( diff --git a/packages/databricks-vscode/src/sdk-extensions/wsfs/WorkspaceFsFile.ts b/packages/databricks-vscode/src/sdk-extensions/wsfs/WorkspaceFsFile.ts index f60523d07..f5b0d00fe 100644 --- a/packages/databricks-vscode/src/sdk-extensions/wsfs/WorkspaceFsFile.ts +++ b/packages/databricks-vscode/src/sdk-extensions/wsfs/WorkspaceFsFile.ts @@ -8,6 +8,14 @@ export class WorkspaceFsFile extends WorkspaceFsEntity { override async generateUrl(host: URL): Promise { return `${host.host}#folder/${(await this.parent)?.id ?? ""}`; } + + async readContent(): Promise { + const result = await this._workspaceFsService.export({ + path: this.path, + format: "AUTO", + }); + return Buffer.from(result.content ?? "", "base64"); + } } export class WorkspaceFsNotebook extends WorkspaceFsFile { diff --git a/packages/databricks-vscode/src/vscode-objs/CustomWhenContext.ts b/packages/databricks-vscode/src/vscode-objs/CustomWhenContext.ts index 160f62d99..e531b9b5c 100644 --- a/packages/databricks-vscode/src/vscode-objs/CustomWhenContext.ts +++ b/packages/databricks-vscode/src/vscode-objs/CustomWhenContext.ts @@ -85,16 +85,6 @@ export class CustomWhenContext { ); } - updateShowWorkspaceView() { - commands.executeCommand( - "setContext", - "databricks.feature.views.workspace", - workspaceConfigs.experimetalFeatureOverides.includes( - "views.workspace" - ) - ); - } - setIsActiveFileInActiveWorkspace(value: boolean) { commands.executeCommand( "setContext", diff --git a/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.ts b/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.ts index ff153cf98..e89153e9d 100644 --- a/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.ts +++ b/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.ts @@ -5,12 +5,14 @@ import { WorkspaceFsUtils, } from "../sdk-extensions"; import {context, Context} from "@databricks/sdk-experimental/dist/context"; -import {Disposable, window} from "vscode"; +import {Disposable, Uri, env, window, workspace} from "vscode"; import {ConnectionManager} from "../configuration/ConnectionManager"; import {Loggers} from "../logger"; import {createDirWizard} from "./createDirectoryWizard"; import {WorkspaceFsDataProvider} from "./WorkspaceFsDataProvider"; import {WorkspaceFolderManager} from "../vscode-objs/WorkspaceFolderManager"; +import {WorkspaceFsFileSystemProvider} from "./WorkspaceFsFileSystemProvider"; +import {WorkspaceFsFile} from "../sdk-extensions/wsfs/WorkspaceFsFile"; const withLogContext = logging.withLogContext; @@ -20,7 +22,8 @@ export class WorkspaceFsCommands implements Disposable { constructor( private workspaceFolderManager: WorkspaceFolderManager, private connectionManager: ConnectionManager, - private workspaceFsDataProvider: WorkspaceFsDataProvider + private workspaceFsDataProvider: WorkspaceFsDataProvider, + private fsp?: WorkspaceFsFileSystemProvider ) {} @withLogContext(Loggers.Extension) @@ -122,6 +125,123 @@ export class WorkspaceFsCommands implements Disposable { return await WorkspaceFsEntity.fromPath(wsClient, repoPath); } + async openInBrowser(element: WorkspaceFsEntity) { + const url = await element.url; + await env.openExternal(Uri.parse(url)); + } + + async copyPath(element: WorkspaceFsEntity) { + await env.clipboard.writeText(element.path); + } + + async deleteItem(element: WorkspaceFsEntity) { + const isDir = + element.type === "DIRECTORY" || element.type === "REPO"; + const label = element.basename; + + const answer = await window.showWarningMessage( + `Delete "${label}"?`, + { + modal: true, + detail: isDir + ? `This will permanently delete the folder "${label}" and all its contents.` + : `This will permanently delete the file "${label}".`, + }, + "Delete" + ); + + if (answer !== "Delete") { + return; + } + + try { + await element.delete(isDir); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + window.showErrorMessage(`Failed to delete "${label}": ${msg}`); + return; + } + + this.workspaceFsDataProvider.refresh(); + const uri = Uri.from({scheme: "wsfs", path: element.path}); + this.fsp?.notifyDeleted(uri); + } + + async uploadFile(element?: WorkspaceFsEntity) { + const client = this.connectionManager.workspaceClient; + if (!client) { + window.showErrorMessage("Please login first to upload a file"); + return; + } + + const rootPath = + (element?.type === "DIRECTORY" || element?.type === "REPO" + ? element?.path + : undefined) ?? + this.connectionManager.databricksWorkspace?.currentFsRoot.path; + + const root = await this.getValidRoot(rootPath); + if (!root) { + return; + } + + const picked = await window.showOpenDialog({ + canSelectMany: false, + openLabel: "Upload", + }); + if (!picked || picked.length === 0) { + return; + } + + const srcUri = picked[0]; + const fileName = srcUri.path.split("/").pop() ?? "file"; + const contentBytes = await workspace.fs.readFile(srcUri); + const contentStr = Buffer.from(contentBytes).toString(); + + try { + await root.createFile(fileName, contentStr, true); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + window.showErrorMessage(`Failed to upload "${fileName}": ${msg}`); + return; + } + + this.workspaceFsDataProvider.refresh(); + const uri = Uri.from({ + scheme: "wsfs", + path: `${root.path}/${fileName}`, + }); + this.fsp?.notifyCreated(uri); + } + + async downloadFile(element: WorkspaceFsEntity) { + if (!(element instanceof WorkspaceFsFile)) { + window.showErrorMessage("Can only download files and notebooks"); + return; + } + + const destUri = await window.showSaveDialog({ + defaultUri: Uri.file(element.basename), + saveLabel: "Download", + }); + if (!destUri) { + return; + } + + let content: Uint8Array; + try { + content = await element.readContent(); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + window.showErrorMessage( + `Failed to download "${element.basename}": ${msg}` + ); + return; + } + + await workspace.fs.writeFile(destUri, content); + } + async refresh() { this.workspaceFsDataProvider.refresh(); } diff --git a/packages/databricks-vscode/src/workspace-fs/WorkspaceFsDataProvider.ts b/packages/databricks-vscode/src/workspace-fs/WorkspaceFsDataProvider.ts index 29a04987b..ef32becbf 100644 --- a/packages/databricks-vscode/src/workspace-fs/WorkspaceFsDataProvider.ts +++ b/packages/databricks-vscode/src/workspace-fs/WorkspaceFsDataProvider.ts @@ -3,6 +3,7 @@ import {posix} from "path"; import { Disposable, EventEmitter, + MarkdownString, TreeDataProvider, TreeItem, ThemeIcon, @@ -37,10 +38,16 @@ export class WorkspaceFsDataProvider getTreeItem( element: WorkspaceFsEntity ): IFsTreeItem | Thenable { + const wsfsUri = Uri.from({scheme: "wsfs", path: element.path}); let treeItem: IFsTreeItem = { label: posix.basename(element.path), - path: Uri.from({scheme: "wsfs", path: element.path}), + path: wsfsUri, url: element.url, + tooltip: new MarkdownString( + `**${posix.basename(element.path)}**\n\n` + + `Path: \`${element.path}\`\n\n` + + `Type: ${element.type}` + ), }; switch (element.type) { case "DIRECTORY": @@ -72,6 +79,12 @@ export class WorkspaceFsDataProvider "file", new ThemeColor("charts.blue") ), + contextValue: "wsfs.file", + command: { + command: "vscode.open", + title: "Open File", + arguments: [wsfsUri], + }, }; break; case "NOTEBOOK": @@ -81,6 +94,12 @@ export class WorkspaceFsDataProvider "notebook", new ThemeColor("charts.orange") ), + contextValue: "wsfs.notebook", + command: { + command: "databricks.wsfs.openInBrowser", + title: "Open in Browser", + arguments: [element], + }, }; break; } diff --git a/packages/databricks-vscode/src/workspace-fs/WorkspaceFsFileSystemProvider.ts b/packages/databricks-vscode/src/workspace-fs/WorkspaceFsFileSystemProvider.ts new file mode 100644 index 000000000..75b50bd1c --- /dev/null +++ b/packages/databricks-vscode/src/workspace-fs/WorkspaceFsFileSystemProvider.ts @@ -0,0 +1,139 @@ +import { + Disposable, + EventEmitter, + FileChangeEvent, + FileChangeType, + FileStat, + FileSystemError, + FileSystemProvider, + FileType, + Uri, +} from "vscode"; +import {ConnectionManager} from "../configuration/ConnectionManager"; +import {WorkspaceFsEntity} from "../sdk-extensions/wsfs/WorkspaceFsEntity"; +import {WorkspaceFsFile} from "../sdk-extensions/wsfs/WorkspaceFsFile"; + +export class WorkspaceFsFileSystemProvider + implements FileSystemProvider, Disposable +{ + private _onDidChangeFile = new EventEmitter(); + readonly onDidChangeFile = this._onDidChangeFile.event; + + constructor(private readonly connectionManager: ConnectionManager) {} + + private requireClient() { + const client = this.connectionManager.workspaceClient; + if (!client) { + throw FileSystemError.Unavailable( + "Not connected to a Databricks workspace" + ); + } + return client; + } + + async stat(uri: Uri): Promise { + const client = this.requireClient(); + const entity = await WorkspaceFsEntity.fromPath(client, uri.path); + if (!entity) { + throw FileSystemError.FileNotFound(uri); + } + const isDir = + entity.type === "DIRECTORY" || entity.type === "REPO"; + return { + type: isDir ? FileType.Directory : FileType.File, + ctime: 0, + mtime: 0, + size: 0, + }; + } + + async readFile(uri: Uri): Promise { + const client = this.requireClient(); + const entity = await WorkspaceFsEntity.fromPath(client, uri.path); + if (!entity) { + throw FileSystemError.FileNotFound(uri); + } + if (!(entity instanceof WorkspaceFsFile)) { + throw FileSystemError.NoPermissions(uri); + } + return entity.readContent(); + } + + async writeFile( + uri: Uri, + content: Uint8Array, + _options: {create: boolean; overwrite: boolean} + ): Promise { + const client = this.requireClient(); + const entity = await WorkspaceFsEntity.fromPath(client, uri.path); + if (!entity) { + throw FileSystemError.FileNotFound(uri); + } + const parent = await entity.parent; + if (!parent) { + throw FileSystemError.FileNotFound(uri); + } + const {WorkspaceFsDir} = await import( + "../sdk-extensions/wsfs/WorkspaceFsDir" + ); + if (!(parent instanceof WorkspaceFsDir)) { + throw FileSystemError.NoPermissions(uri); + } + await parent.createFile( + entity.basename, + Buffer.from(content).toString(), + true + ); + this.notifyChanged(uri); + } + + async readDirectory(uri: Uri): Promise<[string, FileType][]> { + const client = this.requireClient(); + const entity = await WorkspaceFsEntity.fromPath(client, uri.path); + if (!entity) { + throw FileSystemError.FileNotFound(uri); + } + const children = await entity.children; + return children.map((child) => { + const isDir = + child.type === "DIRECTORY" || child.type === "REPO"; + return [child.basename, isDir ? FileType.Directory : FileType.File]; + }); + } + + createDirectory(_uri: Uri): void { + throw FileSystemError.NoPermissions( + "Use the Create Folder command to create directories" + ); + } + + delete(_uri: Uri, _options: {recursive: boolean}): void { + throw FileSystemError.NoPermissions( + "Use the Delete command to delete items" + ); + } + + rename(_oldUri: Uri, _newUri: Uri, _options: {overwrite: boolean}): void { + throw FileSystemError.NoPermissions("Rename is not supported"); + } + + watch(_uri: Uri): Disposable { + return new Disposable(() => {}); + } + + notifyChanged(uri: Uri) { + this._onDidChangeFile.fire([{type: FileChangeType.Changed, uri}]); + } + + notifyCreated(uri: Uri) { + this._onDidChangeFile.fire([{type: FileChangeType.Created, uri}]); + } + + notifyDeleted(uri: Uri) { + this._onDidChangeFile.fire([{type: FileChangeType.Deleted, uri}]); + } + + dispose() { + this._onDidChangeFile.dispose(); + } +} diff --git a/packages/databricks-vscode/src/workspace-fs/index.ts b/packages/databricks-vscode/src/workspace-fs/index.ts index e5f73a55e..4e64c67e7 100644 --- a/packages/databricks-vscode/src/workspace-fs/index.ts +++ b/packages/databricks-vscode/src/workspace-fs/index.ts @@ -1,2 +1,3 @@ export * from "./WorkspaceFsDataProvider"; export * from "./WorkspaceFsCommands"; +export * from "./WorkspaceFsFileSystemProvider"; From 6a3f5cf25dd427131dca202b00843e5d29336b44 Mon Sep 17 00:00:00 2001 From: misha-db Date: Mon, 13 Apr 2026 19:36:15 +0400 Subject: [PATCH 2/8] Fix open in browser --- packages/databricks-vscode/package.json | 4 ++-- .../src/sdk-extensions/wsfs/WorkspaceFsDir.ts | 2 +- .../src/sdk-extensions/wsfs/WorkspaceFsFile.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/databricks-vscode/package.json b/packages/databricks-vscode/package.json index 02ab2fa30..8b20cdbc5 100644 --- a/packages/databricks-vscode/package.json +++ b/packages/databricks-vscode/package.json @@ -179,14 +179,14 @@ "command": "databricks.wsfs.refresh", "title": "Refresh workspace filesystem view", "icon": "$(refresh)", - "enablement": "databricks.context.activated && databricks.context.loggedIn && config.databricks.sync.destinationType == workspace && databricks.feature.views.workspace && !databricks.context.remoteMode", + "enablement": "databricks.context.activated && databricks.context.loggedIn && config.databricks.sync.destinationType == workspace && !databricks.context.remoteMode", "category": "Databricks" }, { "command": "databricks.wsfs.createFolder", "title": "Create Folder", "icon": "$(new-folder)", - "enablement": "databricks.context.activated && databricks.context.loggedIn && databricks.feature.views.workspace && !databricks.context.remoteMode", + "enablement": "databricks.context.activated && databricks.context.loggedIn && !databricks.context.remoteMode", "category": "Databricks" }, { diff --git a/packages/databricks-vscode/src/sdk-extensions/wsfs/WorkspaceFsDir.ts b/packages/databricks-vscode/src/sdk-extensions/wsfs/WorkspaceFsDir.ts index d8e38edb8..af6e73e2e 100644 --- a/packages/databricks-vscode/src/sdk-extensions/wsfs/WorkspaceFsDir.ts +++ b/packages/databricks-vscode/src/sdk-extensions/wsfs/WorkspaceFsDir.ts @@ -6,7 +6,7 @@ import {isDirectory, isFile} from "./utils"; export class WorkspaceFsDir extends WorkspaceFsEntity { override async generateUrl(host: URL): Promise { - return `${host.host}/browse/folders/${this.details.object_id}`; + return `${host.origin}/browse/folders/${this.details.object_id}`; } public getAbsoluteChildPath(path: string) { diff --git a/packages/databricks-vscode/src/sdk-extensions/wsfs/WorkspaceFsFile.ts b/packages/databricks-vscode/src/sdk-extensions/wsfs/WorkspaceFsFile.ts index f5b0d00fe..27ae129ab 100644 --- a/packages/databricks-vscode/src/sdk-extensions/wsfs/WorkspaceFsFile.ts +++ b/packages/databricks-vscode/src/sdk-extensions/wsfs/WorkspaceFsFile.ts @@ -6,7 +6,7 @@ export class WorkspaceFsFile extends WorkspaceFsEntity { } override async generateUrl(host: URL): Promise { - return `${host.host}#folder/${(await this.parent)?.id ?? ""}`; + return `${host.origin}/editor/files/${this.details.object_id}`; } async readContent(): Promise { From 45e9ad94bb33bb3341fe07e1b4c4f41157ac4d7b Mon Sep 17 00:00:00 2001 From: misha-db Date: Tue, 14 Apr 2026 18:51:18 +0400 Subject: [PATCH 3/8] Change label "Workspace explorer" to "Workspace file system" --- packages/databricks-vscode/package.json | 2 +- packages/databricks-vscode/src/test/e2e/utils/commonUtils.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/databricks-vscode/package.json b/packages/databricks-vscode/package.json index 8b20cdbc5..aaa88d23e 100644 --- a/packages/databricks-vscode/package.json +++ b/packages/databricks-vscode/package.json @@ -479,7 +479,7 @@ }, { "id": "workspaceFsView", - "name": "Workspace explorer", + "name": "Workspace file system", "when": "!databricks.context.remoteMode" }, { diff --git a/packages/databricks-vscode/src/test/e2e/utils/commonUtils.ts b/packages/databricks-vscode/src/test/e2e/utils/commonUtils.ts index 752e90edc..64367f8bc 100644 --- a/packages/databricks-vscode/src/test/e2e/utils/commonUtils.ts +++ b/packages/databricks-vscode/src/test/e2e/utils/commonUtils.ts @@ -12,7 +12,7 @@ import { const ViewSectionTypes = [ "CLUSTERS", "CONFIGURATION", - "WORKSPACE EXPLORER", + "WORKSPACE FILE SYSTEM", "BUNDLE RESOURCE EXPLORER", "BUNDLE VARIABLES", "DOCUMENTATION", From a51cdf1812cc0d016b48c289fffcab2b193e442d Mon Sep 17 00:00:00 2001 From: misha-db Date: Wed, 15 Apr 2026 16:47:21 +0400 Subject: [PATCH 4/8] Revert "Update Databricks CLI to v0.295.0 (#1863)" This reverts commit 2d4058ecb113c48e31b64b6645cbe2d447468613. --- packages/databricks-vscode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/databricks-vscode/package.json b/packages/databricks-vscode/package.json index aaa88d23e..4b6f574ba 100644 --- a/packages/databricks-vscode/package.json +++ b/packages/databricks-vscode/package.json @@ -1240,7 +1240,7 @@ "useYarn": false }, "cli": { - "version": "0.295.0" + "version": "0.286.0" }, "scripts": { "vscode:prepublish": "rm -rf out && yarn run package:compile && yarn run package:wrappers:write && yarn run package:jupyter-init-script:write && yarn run package:copy-webview-toolkit && yarn run generate-telemetry", From d39fd7f67c2f8298f4bbc8d9afd88a8a594840cc Mon Sep 17 00:00:00 2001 From: misha-db Date: Wed, 15 Apr 2026 17:11:08 +0400 Subject: [PATCH 5/8] Reapply "Update Databricks CLI to v0.295.0 (#1863)" This reverts commit a51cdf1812cc0d016b48c289fffcab2b193e442d. --- packages/databricks-vscode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/databricks-vscode/package.json b/packages/databricks-vscode/package.json index 4b6f574ba..aaa88d23e 100644 --- a/packages/databricks-vscode/package.json +++ b/packages/databricks-vscode/package.json @@ -1240,7 +1240,7 @@ "useYarn": false }, "cli": { - "version": "0.286.0" + "version": "0.295.0" }, "scripts": { "vscode:prepublish": "rm -rf out && yarn run package:compile && yarn run package:wrappers:write && yarn run package:jupyter-init-script:write && yarn run package:copy-webview-toolkit && yarn run generate-telemetry", From 52cb2cac5dc08f666d51abc62b439a25f69402a6 Mon Sep 17 00:00:00 2001 From: misha-db Date: Tue, 26 May 2026 15:42:04 +0400 Subject: [PATCH 6/8] Add e2e tests --- .../src/test/e2e/wsfs_explorer.e2e.ts | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 packages/databricks-vscode/src/test/e2e/wsfs_explorer.e2e.ts diff --git a/packages/databricks-vscode/src/test/e2e/wsfs_explorer.e2e.ts b/packages/databricks-vscode/src/test/e2e/wsfs_explorer.e2e.ts new file mode 100644 index 000000000..0e1870c99 --- /dev/null +++ b/packages/databricks-vscode/src/test/e2e/wsfs_explorer.e2e.ts @@ -0,0 +1,279 @@ +import assert from "node:assert"; +import { + dismissNotifications, + getTabByTitle, + getUniqueResourceName, + getViewSection, + waitForInput, + waitForLogin, +} from "./utils/commonUtils.ts"; +import { + getBasicBundleConfig, + writeRootBundleConfig, +} from "./utils/dabsFixtures.ts"; +import {WorkspaceClient} from "@databricks/sdk-experimental"; +import {CustomTreeSection, TreeItem} from "wdio-vscode-service"; + +describe("WSFS Explorer", async function () { + let vscodeWorkspaceRoot: string; + let workspaceClient: WorkspaceClient; + let folderName: string; + let wsfsSection: CustomTreeSection; + let userName: string; + const testFileName = "wsfs_e2e_test.py"; + + this.timeout(3 * 60 * 1000); + + before(async function () { + assert( + process.env.WORKSPACE_PATH, + "WORKSPACE_PATH env var doesn't exist" + ); + assert( + process.env.DATABRICKS_HOST, + "DATABRICKS_HOST env var doesn't exist" + ); + assert( + process.env.DATABRICKS_TOKEN, + "DATABRICKS_TOKEN env var doesn't exist" + ); + + vscodeWorkspaceRoot = process.env.WORKSPACE_PATH; + folderName = getUniqueResourceName("wsfs_folder"); + workspaceClient = new WorkspaceClient({ + host: process.env.DATABRICKS_HOST, + token: process.env.DATABRICKS_TOKEN, + }); + + const me = await workspaceClient.currentUser.me(); + userName = me.userName!; + + await workspaceClient.workspace.mkdirs({ + path: `/Users/${userName}/${folderName}`, + }); + + await writeRootBundleConfig( + getBasicBundleConfig(), + vscodeWorkspaceRoot + ); + await waitForLogin("DEFAULT"); + await dismissNotifications(); + wsfsSection = (await getViewSection( + "WORKSPACE FILE SYSTEM" + )) as CustomTreeSection; + }); + + after(async function () { + const me = await workspaceClient.currentUser.me(); + try { + await workspaceClient.workspace.delete({ + path: `/Users/${me.userName}/${folderName}`, + recursive: true, + }); + } catch { + // ignore, folder may already be gone + } + }); + + it("should load the tree view with items", async function () { + assert(wsfsSection, "WORKSPACE FILE SYSTEM section doesn't exist"); + + const hasItems = await browser.waitUntil( + async () => { + const items = await wsfsSection.getVisibleItems(); + return items.length > 0; + }, + { + timeout: 30_000, + interval: 1_000, + timeoutMsg: "WORKSPACE FILE SYSTEM tree view has no items", + } + ); + assert(hasItems, "Tree view should have items"); + + const items = await wsfsSection.getVisibleItems(); + const labels = await Promise.all(items.map((i) => i.getLabel())); + assert( + labels.some((l) => l.includes(folderName)), + `Expected "${folderName}" in tree view, got: ${labels.join(", ")}` + ); + }); + + it("should create a folder via command", async function () { + const fullPath = `/Users/${userName}/${folderName}`; + + await browser.executeWorkbench((vscode) => { + vscode.commands.executeCommand("databricks.wsfs.createFolder"); + }); + + const input = await waitForInput(); + + await browser.keys(folderName.split("")); + await input.confirm(); + + await browser.waitUntil( + async () => { + const items = await wsfsSection.getVisibleItems(); + const labels = await Promise.all( + items.map((i) => i.getLabel()) + ); + return labels.some((l) => l === folderName); + }, + { + timeout: 30_000, + interval: 1_000, + timeoutMsg: `Folder "${folderName}" did not appear in the tree view`, + } + ); + + // Verify via API that the folder was actually created + const stat = await workspaceClient.workspace.getStatus({ + path: fullPath, + }); + assert.strictEqual(stat.object_type, "DIRECTORY"); + }); + + it("should open a file in the editor when clicked in the tree", async function () { + const filePath = `/Users/${userName}/${folderName}/${testFileName}`; + + // Create the test file via API (folder already exists from the previous test) + await workspaceClient.workspace.import({ + path: filePath, + format: "AUTO", + content: Buffer.from( + "# e2e test file\nprint('hello wsfs')\n" + ).toString("base64"), + overwrite: true, + }); + + // Refresh tree so the new file appears + await browser.executeWorkbench((vscode) => { + vscode.commands.executeCommand("databricks.wsfs.refresh"); + }); + + // Find folderName, expand it, then locate the child file + let targetItem: TreeItem | undefined; + await browser.waitUntil( + async () => { + const folder = await wsfsSection.findItem(folderName, 1); + if (folder && !(await folder.isExpanded())) { + await folder.expand(); + } + targetItem = await wsfsSection.findItem(testFileName); + return targetItem !== undefined; + }, + { + timeout: 30_000, + interval: 2_000, + timeoutMsg: `File "${testFileName}" not found in tree`, + } + ); + + // Click the file to open it (triggers the vscode.open command registered by getTreeItem) + await targetItem!.select(); + + // Wait for an editor tab with the filename to appear + const tab = await browser.waitUntil( + async () => { + try { + return await getTabByTitle(testFileName); + } catch { + return undefined; + } + }, + { + timeout: 30_000, + interval: 1_000, + timeoutMsg: `Editor tab for "${testFileName}" did not appear`, + } + ); + assert(tab, `Editor tab for "${testFileName}" did not appear`); + }); + + it("should edit and save a file, persisting changes to the Databricks workspace", async function () { + const filePath = `/Users/${userName}/${folderName}/${testFileName}`; + const newContent = "# edited by e2e test\nprint('updated')\n"; + + // Write through the WSFS filesystem provider directly more reliable + // than clipboard-based editor interaction in headless test environments. + await browser.executeWorkbench( + async (vscode, wsfsPath, wsfsContent) => { + const uri = vscode.Uri.from({scheme: "wsfs", path: wsfsPath}); + await vscode.workspace.fs.writeFile( + uri, + Buffer.from(wsfsContent) + ); + }, + filePath, + newContent + ); + + await browser.waitUntil( + async () => { + const exported = await workspaceClient.workspace.export({ + path: filePath, + format: "AUTO", + }); + const content = Buffer.from( + exported.content ?? "", + "base64" + ).toString(); + return content.includes("edited by e2e test"); + }, + { + timeout: 30_000, + interval: 2_000, + timeoutMsg: + "File content did not update in Databricks workspace after save", + } + ); + }); + + it("should reflect deletion after API delete + refresh", async function () { + const userName = (await workspaceClient.currentUser.me()).userName; + const fullPath = `/Users/${userName}/${folderName}`; + + await workspaceClient.workspace.delete({ + path: fullPath, + recursive: true, + }); + + await browser.executeWorkbench((vscode) => { + vscode.commands.executeCommand("databricks.wsfs.refresh"); + }); + + await browser.waitUntil( + async () => { + const items = await wsfsSection.getVisibleItems(); + const labels = await Promise.all( + items.map((i) => i.getLabel()) + ); + return !labels.some((l) => l.includes(folderName)); + }, + { + timeout: 30_000, + interval: 1_000, + timeoutMsg: `Folder "${folderName}" is still visible after deletion`, + } + ); + }); + + it("should refresh the tree view", async function () { + await browser.executeWorkbench((vscode) => { + vscode.commands.executeCommand("databricks.wsfs.refresh"); + }); + + const hasItems = await browser.waitUntil( + async () => { + const items = await wsfsSection.getVisibleItems(); + return items.length > 0; + }, + { + timeout: 30_000, + interval: 1_000, + timeoutMsg: "Tree view is empty after refresh", + } + ); + assert(hasItems, "Tree view should still have items after refresh"); + }); +}); From da20db59d58a951f9fdd7a86a4ea25d7de6a3856 Mon Sep 17 00:00:00 2001 From: misha-db Date: Tue, 26 May 2026 18:39:01 +0400 Subject: [PATCH 7/8] Fix bugs --- .../src/sdk-extensions/wsfs/WorkspaceFsDir.ts | 2 +- .../src/test/e2e/wsfs_explorer.e2e.ts | 20 +++++++--- .../src/workspace-fs/WorkspaceFsCommands.ts | 38 +++++++++--------- .../WorkspaceFsFileSystemProvider.ts | 40 +++++++++++-------- 4 files changed, 59 insertions(+), 41 deletions(-) diff --git a/packages/databricks-vscode/src/sdk-extensions/wsfs/WorkspaceFsDir.ts b/packages/databricks-vscode/src/sdk-extensions/wsfs/WorkspaceFsDir.ts index af6e73e2e..bc60f9670 100644 --- a/packages/databricks-vscode/src/sdk-extensions/wsfs/WorkspaceFsDir.ts +++ b/packages/databricks-vscode/src/sdk-extensions/wsfs/WorkspaceFsDir.ts @@ -73,7 +73,7 @@ export class WorkspaceFsDir extends WorkspaceFsEntity { @logging.withLogContext(logging.ExposedLoggers.SDK) async createFile( path: string, - content: string, + content: string | Uint8Array, overwrite = true, @context ctx?: Context ) { diff --git a/packages/databricks-vscode/src/test/e2e/wsfs_explorer.e2e.ts b/packages/databricks-vscode/src/test/e2e/wsfs_explorer.e2e.ts index 0e1870c99..dc1175d9b 100644 --- a/packages/databricks-vscode/src/test/e2e/wsfs_explorer.e2e.ts +++ b/packages/databricks-vscode/src/test/e2e/wsfs_explorer.e2e.ts @@ -100,7 +100,8 @@ describe("WSFS Explorer", async function () { }); it("should create a folder via command", async function () { - const fullPath = `/Users/${userName}/${folderName}`; + const newFolderName = getUniqueResourceName("wsfs_created"); + const fullPath = `/Users/${userName}/${newFolderName}`; await browser.executeWorkbench((vscode) => { vscode.commands.executeCommand("databricks.wsfs.createFolder"); @@ -108,7 +109,7 @@ describe("WSFS Explorer", async function () { const input = await waitForInput(); - await browser.keys(folderName.split("")); + await browser.keys(newFolderName.split("")); await input.confirm(); await browser.waitUntil( @@ -117,20 +118,29 @@ describe("WSFS Explorer", async function () { const labels = await Promise.all( items.map((i) => i.getLabel()) ); - return labels.some((l) => l === folderName); + return labels.some((l) => l === newFolderName); }, { timeout: 30_000, interval: 1_000, - timeoutMsg: `Folder "${folderName}" did not appear in the tree view`, + timeoutMsg: `Folder "${newFolderName}" did not appear in the tree view`, } ); - // Verify via API that the folder was actually created const stat = await workspaceClient.workspace.getStatus({ path: fullPath, }); assert.strictEqual(stat.object_type, "DIRECTORY"); + + // Cleanup + try { + await workspaceClient.workspace.delete({ + path: fullPath, + recursive: true, + }); + } catch { + // ignore + } }); it("should open a file in the editor when clicked in the tree", async function () { diff --git a/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.ts b/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.ts index e89153e9d..60f28a182 100644 --- a/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.ts +++ b/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.ts @@ -23,7 +23,7 @@ export class WorkspaceFsCommands implements Disposable { private workspaceFolderManager: WorkspaceFolderManager, private connectionManager: ConnectionManager, private workspaceFsDataProvider: WorkspaceFsDataProvider, - private fsp?: WorkspaceFsFileSystemProvider + private fsp: WorkspaceFsFileSystemProvider ) {} @withLogContext(Loggers.Extension) @@ -77,26 +77,29 @@ export class WorkspaceFsCommands implements Disposable { this.connectionManager.databricksWorkspace?.currentFsRoot.path; const root = await this.getValidRoot(rootPath, ctx); + if (!root) { + return; + } const inputPath = await createDirWizard( this.workspaceFolderManager.activeProjectUri, "Directory Name", root ); - let created: WorkspaceFsEntity | undefined; - if (inputPath !== undefined) { - try { - if (root) { - created = await root.mkdir(inputPath); - } - } catch (e: unknown) { - if (e instanceof ApiError) { - window.showErrorMessage( - `Can't create directory ${inputPath}: ${e.message}` - ); - return; - } + if (inputPath === undefined) { + return; + } + + let created: WorkspaceFsEntity | undefined; + try { + created = await root.mkdir(inputPath); + } catch (e: unknown) { + if (e instanceof ApiError) { + window.showErrorMessage( + `Can't create directory ${inputPath}: ${e.message}` + ); + return; } } @@ -164,7 +167,7 @@ export class WorkspaceFsCommands implements Disposable { this.workspaceFsDataProvider.refresh(); const uri = Uri.from({scheme: "wsfs", path: element.path}); - this.fsp?.notifyDeleted(uri); + this.fsp.notifyDeleted(uri); } async uploadFile(element?: WorkspaceFsEntity) { @@ -196,10 +199,9 @@ export class WorkspaceFsCommands implements Disposable { const srcUri = picked[0]; const fileName = srcUri.path.split("/").pop() ?? "file"; const contentBytes = await workspace.fs.readFile(srcUri); - const contentStr = Buffer.from(contentBytes).toString(); try { - await root.createFile(fileName, contentStr, true); + await root.createFile(fileName, contentBytes, true); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); window.showErrorMessage(`Failed to upload "${fileName}": ${msg}`); @@ -211,7 +213,7 @@ export class WorkspaceFsCommands implements Disposable { scheme: "wsfs", path: `${root.path}/${fileName}`, }); - this.fsp?.notifyCreated(uri); + this.fsp.notifyCreated(uri); } async downloadFile(element: WorkspaceFsEntity) { diff --git a/packages/databricks-vscode/src/workspace-fs/WorkspaceFsFileSystemProvider.ts b/packages/databricks-vscode/src/workspace-fs/WorkspaceFsFileSystemProvider.ts index 75b50bd1c..864d26aea 100644 --- a/packages/databricks-vscode/src/workspace-fs/WorkspaceFsFileSystemProvider.ts +++ b/packages/databricks-vscode/src/workspace-fs/WorkspaceFsFileSystemProvider.ts @@ -1,3 +1,4 @@ +import {posix} from "path"; import { Disposable, EventEmitter, @@ -41,8 +42,8 @@ export class WorkspaceFsFileSystemProvider entity.type === "DIRECTORY" || entity.type === "REPO"; return { type: isDir ? FileType.Directory : FileType.File, - ctime: 0, - mtime: 0, + ctime: entity.details.created_at ?? 0, + mtime: entity.details.modified_at ?? 0, size: 0, }; } @@ -62,28 +63,33 @@ export class WorkspaceFsFileSystemProvider async writeFile( uri: Uri, content: Uint8Array, - _options: {create: boolean; overwrite: boolean} + options: {create: boolean; overwrite: boolean} ): Promise { const client = this.requireClient(); - const entity = await WorkspaceFsEntity.fromPath(client, uri.path); - if (!entity) { - throw FileSystemError.FileNotFound(uri); - } - const parent = await entity.parent; - if (!parent) { - throw FileSystemError.FileNotFound(uri); - } const {WorkspaceFsDir} = await import( "../sdk-extensions/wsfs/WorkspaceFsDir" ); - if (!(parent instanceof WorkspaceFsDir)) { + + // Resolve parent directory — works for both existing and new files. + const parentPath = posix.dirname(uri.path); + const parentEntity = await WorkspaceFsEntity.fromPath( + client, + parentPath + ); + if (!parentEntity) { + throw FileSystemError.FileNotFound(uri); + } + if (!(parentEntity instanceof WorkspaceFsDir)) { throw FileSystemError.NoPermissions(uri); } - await parent.createFile( - entity.basename, - Buffer.from(content).toString(), - true - ); + + // If the file doesn't exist and create is not requested, reject. + const existing = await WorkspaceFsEntity.fromPath(client, uri.path); + if (!existing && !options.create) { + throw FileSystemError.FileNotFound(uri); + } + + await parentEntity.createFile(posix.basename(uri.path), content, true); this.notifyChanged(uri); } From 3ec7c14e5d9d3621adf4146fc4b1c7bd18624da8 Mon Sep 17 00:00:00 2001 From: misha-db Date: Tue, 26 May 2026 19:02:13 +0400 Subject: [PATCH 8/8] Update package.json --- packages/databricks-vscode/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/databricks-vscode/package.json b/packages/databricks-vscode/package.json index 567408844..d9a1c439a 100644 --- a/packages/databricks-vscode/package.json +++ b/packages/databricks-vscode/package.json @@ -179,7 +179,7 @@ "command": "databricks.wsfs.refresh", "title": "Refresh workspace filesystem view", "icon": "$(refresh)", - "enablement": "databricks.context.activated && databricks.context.loggedIn && config.databricks.sync.destinationType == workspace && !databricks.context.remoteMode", + "enablement": "databricks.context.activated && databricks.context.loggedIn && !databricks.context.remoteMode", "category": "Databricks" }, { @@ -899,11 +899,11 @@ }, { "command": "databricks.wsfs.createFolder", - "when": "config.databricks.sync.destinationType == workspace" + "when": "!databricks.context.remoteMode" }, { "command": "databricks.wsfs.refresh", - "when": "config.databricks.sync.destinationType == workspace" + "when": "!databricks.context.remoteMode" }, { "command": "databricks.wsfs.delete",