diff --git a/packages/databricks-vscode/package.json b/packages/databricks-vscode/package.json index ba3309e33..d9a1c439a 100644 --- a/packages/databricks-vscode/package.json +++ b/packages/databricks-vscode/package.json @@ -179,14 +179,44 @@ "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 && !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" + }, + { + "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" }, { @@ -449,8 +479,8 @@ }, { "id": "workspaceFsView", - "name": "Workspace explorer", - "when": "databricks.feature.views.workspace && !databricks.context.remoteMode" + "name": "Workspace file system", + "when": "!databricks.context.remoteMode" }, { "id": "databricksDocsView", @@ -536,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", @@ -582,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", @@ -834,11 +899,31 @@ }, { "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", + "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", 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/WorkspaceFsDir.ts b/packages/databricks-vscode/src/sdk-extensions/wsfs/WorkspaceFsDir.ts index d8e38edb8..bc60f9670 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) { @@ -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/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..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,15 @@ 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 { + const result = await this._workspaceFsService.export({ + path: this.path, + format: "AUTO", + }); + return Buffer.from(result.content ?? "", "base64"); } } 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", 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..dc1175d9b --- /dev/null +++ b/packages/databricks-vscode/src/test/e2e/wsfs_explorer.e2e.ts @@ -0,0 +1,289 @@ +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 newFolderName = getUniqueResourceName("wsfs_created"); + const fullPath = `/Users/${userName}/${newFolderName}`; + + await browser.executeWorkbench((vscode) => { + vscode.commands.executeCommand("databricks.wsfs.createFolder"); + }); + + const input = await waitForInput(); + + await browser.keys(newFolderName.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 === newFolderName); + }, + { + timeout: 30_000, + interval: 1_000, + timeoutMsg: `Folder "${newFolderName}" did not appear in the tree view`, + } + ); + + 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 () { + 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"); + }); +}); 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..60f28a182 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) @@ -74,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; } } @@ -122,6 +128,122 @@ 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); + + try { + 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}`); + 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..864d26aea --- /dev/null +++ b/packages/databricks-vscode/src/workspace-fs/WorkspaceFsFileSystemProvider.ts @@ -0,0 +1,145 @@ +import {posix} from "path"; +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: entity.details.created_at ?? 0, + mtime: entity.details.modified_at ?? 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 {WorkspaceFsDir} = await import( + "../sdk-extensions/wsfs/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); + } + + // 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); + } + + 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";