diff --git a/packages/databricks-vscode/package.json b/packages/databricks-vscode/package.json index cb6d6bbe0..2275f8dc6 100644 --- a/packages/databricks-vscode/package.json +++ b/packages/databricks-vscode/package.json @@ -196,6 +196,20 @@ "enablement": "databricks.context.activated && databricks.context.loggedIn && !databricks.context.remoteMode", "category": "Databricks" }, + { + "command": "databricks.wsfs.createNewFile", + "title": "Create File", + "icon": "$(new-file)", + "enablement": "databricks.context.activated && databricks.context.loggedIn && !databricks.context.remoteMode", + "category": "Databricks" + }, + { + "command": "databricks.wsfs.createNewFile.toolbar", + "title": "Create File", + "icon": "$(new-file)", + "enablement": "databricks.context.activated && databricks.context.loggedIn && !databricks.context.remoteMode", + "category": "Databricks" + }, { "command": "databricks.wsfs.openInBrowser", "title": "Open in Browser", @@ -726,6 +740,11 @@ "when": "view == workspaceFsView", "group": "navigation@1" }, + { + "command": "databricks.wsfs.createNewFile.toolbar", + "when": "view == workspaceFsView", + "group": "navigation@1" + }, { "command": "databricks.unityCatalog.filter", "when": "view == unityCatalogView", @@ -803,10 +822,15 @@ "group": "wsfs_mut@0" }, { - "command": "databricks.wsfs.uploadFile", + "command": "databricks.wsfs.createNewFile", "when": "view == workspaceFsView && (viewItem == wsfs.directory || viewItem == wsfs.repo)", "group": "wsfs_mut@1" }, + { + "command": "databricks.wsfs.uploadFile", + "when": "view == workspaceFsView && (viewItem == wsfs.directory || viewItem == wsfs.repo)", + "group": "wsfs_mut@2" + }, { "command": "databricks.wsfs.downloadFile", "when": "view == workspaceFsView && (viewItem == wsfs.file || viewItem == wsfs.notebook)", diff --git a/packages/databricks-vscode/src/extension.ts b/packages/databricks-vscode/src/extension.ts index 69ca1fc94..995255a63 100644 --- a/packages/databricks-vscode/src/extension.ts +++ b/packages/databricks-vscode/src/extension.ts @@ -429,6 +429,16 @@ export async function activate( workspaceFsCommands.createFolderFromToolbar, workspaceFsCommands ), + telemetry.registerCommand( + "databricks.wsfs.createNewFile", + workspaceFsCommands.createFile, + workspaceFsCommands + ), + telemetry.registerCommand( + "databricks.wsfs.createNewFile.toolbar", + workspaceFsCommands.createFileFromToolbar, + workspaceFsCommands + ), telemetry.registerCommand( "databricks.wsfs.openInBrowser", workspaceFsCommands.openInBrowser, diff --git a/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.test.ts b/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.test.ts index 3c7c197c7..bc6226b09 100644 --- a/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.test.ts +++ b/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.test.ts @@ -1,5 +1,5 @@ import assert from "assert"; -import {EventEmitter, TreeView} from "vscode"; +import {EventEmitter, TreeView, Uri, window, workspace} from "vscode"; import {mock, instance, when} from "ts-mockito"; import {WorkspaceFsCommands} from "./WorkspaceFsCommands"; import {WorkspaceFsEntity} from "../sdk-extensions"; @@ -126,6 +126,59 @@ describe("WorkspaceFsCommands – target folder resolution", () => { }); }); + // createFile mirrors createFolder's target resolution but, like upload, + // checks workspaceClient before reaching getValidRoot. + describe("createFile (context menu)", () => { + beforeEach(() => { + when(mockConnectionManager.workspaceClient).thenReturn({} as any); + }); + + it("no element → targets root", async () => { + await commands.createFile(makeEntity(ROOT_PATH)); + assert.strictEqual(capturedRootPath, ROOT_PATH); + }); + + it("element=A → targets A", async () => { + await commands.createFile(entityA); + assert.strictEqual(capturedRootPath, entityA.path); + }); + + it("element=B while A is selected → targets B", async () => { + fakeTreeView.simulateSelect(entityA); + await commands.createFile(entityB); + assert.strictEqual(capturedRootPath, entityB.path); + }); + }); + + describe("createFileFromToolbar (toolbar)", () => { + beforeEach(() => { + when(mockConnectionManager.workspaceClient).thenReturn({} as any); + }); + + it("nothing selected → targets root", async () => { + await commands.createFileFromToolbar(undefined); + assert.strictEqual(capturedRootPath, ROOT_PATH); + }); + + it("A selected, toolbar clicked → targets A", async () => { + fakeTreeView.simulateSelect(entityA); + await commands.createFileFromToolbar(entityA); + assert.strictEqual(capturedRootPath, entityA.path); + }); + + it("selection cleared before toolbar click → targets root", async () => { + fakeTreeView.simulateSelect(entityA); + fakeTreeView.simulateSelect(undefined); + await commands.createFileFromToolbar(undefined); + assert.strictEqual(capturedRootPath, ROOT_PATH); + }); + + it("element passed but nothing selected (edge case) → targets root", async () => { + await commands.createFileFromToolbar(entityA); + assert.strictEqual(capturedRootPath, ROOT_PATH); + }); + }); + describe("uploadFile (context menu)", () => { // doUploadFile checks workspaceClient before reaching getValidRoot; // provide a non-null client so root-path resolution is exercised. @@ -190,3 +243,214 @@ describe("WorkspaceFsCommands – target folder resolution", () => { }); }); }); + +describe("WorkspaceFsCommands – createFile content", () => { + const ROOT_PATH = "/Users/me"; + + let commands: WorkspaceFsCommands; + let capturedCreate: {path: string; content: string} | undefined; + let lookedUpPaths: string[]; + let warningMessages: string[]; + let openedUri: Uri | undefined; + let browserOpenedPath: string | undefined; + + // Stubbed globals are restored after each test. + let originalShowInputBox: typeof window.showInputBox; + let originalShowWarningMessage: typeof window.showWarningMessage; + let originalShowTextDocument: typeof window.showTextDocument; + let originalOpenTextDocument: typeof workspace.openTextDocument; + let originalFromPath: typeof WorkspaceFsEntity.fromPath; + + let inputName: string; + // Path (relative to root) at which fromPath should report an existing + // entity, or undefined for "nothing exists". + let existingAt: string | undefined; + // Whether the user clicks "Overwrite" on the prompt. + let overwriteAnswer: string | undefined; + + beforeEach(() => { + existingAt = undefined; + overwriteAnswer = "Overwrite"; + lookedUpPaths = []; + warningMessages = []; + openedUri = undefined; + browserOpenedPath = undefined; + const mockConnectionManager = mock(); + when(mockConnectionManager.workspaceClient).thenReturn({} as any); + + // createDirWizard reads activeProjectUri to seed the input box. + const mockWorkspaceFolderManager = mock(); + when(mockWorkspaceFolderManager.activeProjectUri).thenReturn( + Uri.file("/tmp/project") + ); + + commands = new WorkspaceFsCommands( + instance(mockWorkspaceFolderManager), + instance(mockConnectionManager), + instance(mock()), + instance(mock()), + new FakeTreeView() as unknown as TreeView + ); + + // Fake root that captures what doCreateFile writes. createFile mimics + // the SDK: a `.ipynb` is stored as a notebook at the stripped path. + capturedCreate = undefined; + const fakeRoot = { + path: ROOT_PATH, + createFile: async (path: string, content: string) => { + capturedCreate = {path, content}; + const storedName = path.replace(/\.ipynb$/i, ""); + return { + path: `${ROOT_PATH}/${storedName}`, + } as unknown as WorkspaceFsEntity; + }, + }; + (commands as any).getValidRoot = async () => fakeRoot; + + // Capture browser opens (used for notebooks) instead of launching one. + (commands as any).openInBrowser = async ( + element: WorkspaceFsEntity + ) => { + browserOpenedPath = element.path; + }; + + // createDirWizard reads the filename from showInputBox. + originalShowInputBox = window.showInputBox; + (window as any).showInputBox = async () => inputName; + + // fromPath reports an existing entity only at `existingAt`. + originalFromPath = WorkspaceFsEntity.fromPath; + (WorkspaceFsEntity as any).fromPath = async ( + _client: unknown, + path: string + ) => { + lookedUpPaths.push(path); + return existingAt !== undefined && + path === `${ROOT_PATH}/${existingAt}` + ? ({path} as unknown as WorkspaceFsEntity) + : undefined; + }; + + // Capture the overwrite prompt and return the canned answer. + originalShowWarningMessage = window.showWarningMessage; + (window as any).showWarningMessage = async (message: string) => { + warningMessages.push(message); + return overwriteAnswer; + }; + + // Avoid actually opening an editor after creation. + originalShowTextDocument = window.showTextDocument; + (window as any).showTextDocument = async () => undefined; + originalOpenTextDocument = workspace.openTextDocument; + (workspace as any).openTextDocument = async (uri: Uri) => { + openedUri = uri; + return uri; + }; + }); + + afterEach(() => { + (window as any).showInputBox = originalShowInputBox; + (window as any).showWarningMessage = originalShowWarningMessage; + (window as any).showTextDocument = originalShowTextDocument; + (workspace as any).openTextDocument = originalOpenTextDocument; + (WorkspaceFsEntity as any).fromPath = originalFromPath; + }); + + it(".ipynb file is created with valid empty notebook JSON", async () => { + inputName = "notebook.ipynb"; + await commands.createFile(makeEntity(ROOT_PATH)); + + assert.ok(capturedCreate, "createFile should have been called"); + assert.strictEqual(capturedCreate!.path, "notebook.ipynb"); + + const parsed = JSON.parse(capturedCreate!.content); + assert.deepStrictEqual(parsed.cells, []); + assert.strictEqual(parsed.nbformat, 4); + assert.strictEqual(parsed.nbformat_minor, 5); + assert.strictEqual(parsed.metadata.language_info.name, "python"); + }); + + it(".IPYNB extension is matched case-insensitively", async () => { + inputName = "NoteBook.IPYNB"; + await commands.createFile(makeEntity(ROOT_PATH)); + + assert.ok(capturedCreate); + const parsed = JSON.parse(capturedCreate!.content); + assert.strictEqual(parsed.nbformat, 4); + }); + + it(".py file is created with empty content", async () => { + inputName = "script.py"; + await commands.createFile(makeEntity(ROOT_PATH)); + + assert.ok(capturedCreate); + assert.strictEqual(capturedCreate!.content, ""); + }); + + it("plain file is created with empty content", async () => { + inputName = "notes.txt"; + await commands.createFile(makeEntity(ROOT_PATH)); + + assert.ok(capturedCreate); + assert.strictEqual(capturedCreate!.content, ""); + }); + + it(".ipynb existence check uses the extension-stripped path", async () => { + inputName = "notebook.ipynb"; + await commands.createFile(makeEntity(ROOT_PATH)); + + // The notebook clash is detected at the path without `.ipynb`. + assert.ok( + lookedUpPaths.includes(`${ROOT_PATH}/notebook`), + `expected lookup at stripped path, got ${JSON.stringify( + lookedUpPaths + )}` + ); + assert.ok(!lookedUpPaths.includes(`${ROOT_PATH}/notebook.ipynb`)); + }); + + it("prompts to overwrite an existing notebook with the same name", async () => { + inputName = "notebook.ipynb"; + existingAt = "notebook"; // notebook stored without extension + await commands.createFile(makeEntity(ROOT_PATH)); + + assert.strictEqual(warningMessages.length, 1); + assert.ok(warningMessages[0].includes('"notebook"')); + // User clicked Overwrite → file is still created. + assert.ok(capturedCreate); + }); + + it("aborts creation when overwrite is declined", async () => { + inputName = "notebook.ipynb"; + existingAt = "notebook"; + overwriteAnswer = undefined; // dismissed / not "Overwrite" + await commands.createFile(makeEntity(ROOT_PATH)); + + assert.strictEqual(warningMessages.length, 1); + assert.strictEqual( + capturedCreate, + undefined, + "createFile must not run when overwrite is declined" + ); + }); + + it("opens the created notebook in the browser at its stripped path", async () => { + inputName = "notebook.ipynb"; + await commands.createFile(makeEntity(ROOT_PATH)); + + assert.strictEqual(browserOpenedPath, `${ROOT_PATH}/notebook`); + // It must NOT also open in the text editor. + assert.strictEqual(openedUri, undefined); + }); + + it("non-notebook files are looked up and opened in the editor by exact name", async () => { + inputName = "script.py"; + await commands.createFile(makeEntity(ROOT_PATH)); + + assert.ok(lookedUpPaths.includes(`${ROOT_PATH}/script.py`)); + assert.ok(openedUri); + assert.strictEqual(openedUri!.path, `${ROOT_PATH}/script.py`); + // Non-notebooks must NOT open in the browser. + assert.strictEqual(browserOpenedPath, undefined); + }); +}); diff --git a/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.ts b/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.ts index 4caac439e..8238ff73f 100644 --- a/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.ts +++ b/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.ts @@ -16,6 +16,29 @@ import {WorkspaceFsFile} from "../sdk-extensions/wsfs/WorkspaceFsFile"; const withLogContext = logging.withLogContext; +/** + * Minimal valid empty Python notebook (nbformat 4.5) used as the initial + * content when creating a `.ipynb` file, so it can be opened as a notebook + * right away instead of as an invalid/empty document. + */ +const EMPTY_IPYNB_CONTENT = JSON.stringify( + { + cells: [], + metadata: { + kernelspec: { + display_name: "Python 3", + language: "python", + name: "python3", + }, + language_info: {name: "python"}, + }, + nbformat: 4, + nbformat_minor: 5, + }, + null, + 1 +); + export class WorkspaceFsCommands implements Disposable { private disposables: Disposable[] = []; @@ -141,6 +164,111 @@ export class WorkspaceFsCommands implements Disposable { return created; } + @withLogContext(Loggers.Extension) + async createFile(element?: WorkspaceFsEntity, @context ctx?: Context) { + const rootPath = + element?.path ?? + this.connectionManager.databricksWorkspace?.currentFsRoot.path; + return this.doCreateFile(rootPath, ctx); + } + + @withLogContext(Loggers.Extension) + async createFileFromToolbar( + element?: WorkspaceFsEntity, + @context ctx?: Context + ) { + const activeElement = this.resolveTargetElementForToolbar(element); + const rootPath = + activeElement?.path ?? + this.connectionManager.databricksWorkspace?.currentFsRoot.path; + return this.doCreateFile(rootPath, ctx); + } + + @withLogContext(Loggers.Extension) + private async doCreateFile( + rootPath: string | undefined, + @context ctx?: Context + ) { + const client = this.connectionManager.workspaceClient; + if (!client) { + window.showErrorMessage("Please login first to create a file"); + return; + } + + const root = await this.getValidRoot(rootPath, ctx); + if (!root) { + return; + } + + const inputName = await createDirWizard( + this.workspaceFolderManager.activeProjectUri, + "File Name", + root + ); + + if (inputName === undefined) { + return; + } + + const isIpynb = inputName.toLowerCase().endsWith(".ipynb"); + const existingName = isIpynb + ? inputName.replace(/\.ipynb$/i, "") + : inputName; + + const existing = await WorkspaceFsEntity.fromPath( + client, + `${root.path}/${existingName}` + ); + if (existing) { + const answer = await window.showWarningMessage( + `"${existingName}" already exists in the workspace. Overwrite it?`, + {modal: true}, + "Overwrite" + ); + if (answer !== "Overwrite") { + return; + } + } + + const content = isIpynb ? EMPTY_IPYNB_CONTENT : ""; + + let created: WorkspaceFsEntity | undefined; + try { + created = await root.createFile(inputName, content, true); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + window.showErrorMessage(`Failed to create "${inputName}": ${msg}`); + return; + } + + this.workspaceFsDataProvider.refresh(); + const uri = Uri.from({ + scheme: WorkspaceFsFileSystemProvider.scheme, + path: created?.path ?? `${root.path}/${inputName}`, + }); + this.fsp.notifyCreated(uri); + + if (isIpynb && created) { + try { + await this.openInBrowser(created); + } catch (e: unknown) { + ctx?.logger?.error( + `Can't open ${inputName} in browser after creation`, + e + ); + } + return; + } + + try { + await window.showTextDocument( + await workspace.openTextDocument(uri) + ); + } catch (e: unknown) { + ctx?.logger?.error(`Can't open ${inputName} after creation`, e); + } + } + private async createRepo(repoPath: string) { const wsClient = this.connectionManager.workspaceClient; if (!wsClient) {