diff --git a/packages/databricks-vscode/src/extension.ts b/packages/databricks-vscode/src/extension.ts index c50503da5..55b0afe88 100644 --- a/packages/databricks-vscode/src/extension.ts +++ b/packages/databricks-vscode/src/extension.ts @@ -393,11 +393,15 @@ export async function activate( connectionManager ); const workspaceFsFsp = new WorkspaceFsFileSystemProvider(connectionManager); + const workspaceFsTreeView = window.createTreeView("workspaceFsView", { + treeDataProvider: workspaceFsDataProvider, + }); const workspaceFsCommands = new WorkspaceFsCommands( workspaceFolderManager, connectionManager, workspaceFsDataProvider, - workspaceFsFsp + workspaceFsFsp, + workspaceFsTreeView ); context.subscriptions.push( @@ -409,10 +413,7 @@ export async function activate( } ), workspaceFsFsp, - window.registerTreeDataProvider( - "workspaceFsView", - workspaceFsDataProvider - ), + workspaceFsTreeView, telemetry.registerCommand( "databricks.wsfs.refresh", workspaceFsCommands.refresh, diff --git a/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.test.ts b/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.test.ts new file mode 100644 index 000000000..4f600854b --- /dev/null +++ b/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.test.ts @@ -0,0 +1,166 @@ +import assert from "assert"; +import {EventEmitter, TreeView} from "vscode"; +import {mock, instance, when} from "ts-mockito"; +import {WorkspaceFsCommands} from "./WorkspaceFsCommands"; +import {WorkspaceFsEntity} from "../sdk-extensions"; +import {ConnectionManager} from "../configuration/ConnectionManager"; +import {WorkspaceFsDataProvider} from "./WorkspaceFsDataProvider"; +import {WorkspaceFsFileSystemProvider} from "./WorkspaceFsFileSystemProvider"; +import {WorkspaceFolderManager} from "../vscode-objs/WorkspaceFolderManager"; +import {DatabricksWorkspace} from "../configuration/DatabricksWorkspace"; +import {RemoteUri} from "../sync/SyncDestination"; + +// Minimal fake TreeView that tracks selection and fires onDidChangeSelection +class FakeTreeView { + selection: ReadonlyArray = []; + private _emitter = new EventEmitter<{ + selection: WorkspaceFsEntity[]; + }>(); + readonly onDidChangeSelection = this._emitter.event; + + simulateSelect(element: WorkspaceFsEntity | undefined): void { + this.selection = element ? [element] : []; + this._emitter.fire({selection: [...this.selection]}); + } +} + +function makeEntity(path: string): WorkspaceFsEntity { + return {path, type: "DIRECTORY"} as unknown as WorkspaceFsEntity; +} + +describe("WorkspaceFsCommands – target folder resolution", () => { + const ROOT_PATH = "/Users/me"; + + let fakeTreeView: FakeTreeView; + let mockConnectionManager: ConnectionManager; + let commands: WorkspaceFsCommands; + let capturedRootPath: string | undefined; + + const entityA = makeEntity("/Users/me/A"); + const entityB = makeEntity("/Users/me/B"); + + beforeEach(() => { + fakeTreeView = new FakeTreeView(); + + const mockDatabricksWorkspace = mock(); + when(mockDatabricksWorkspace.currentFsRoot).thenReturn( + new RemoteUri(ROOT_PATH) + ); + + mockConnectionManager = mock(); + when(mockConnectionManager.workspaceClient).thenReturn( + undefined as any + ); + when(mockConnectionManager.databricksWorkspace).thenReturn( + instance(mockDatabricksWorkspace) + ); + + commands = new WorkspaceFsCommands( + instance(mock()), + instance(mockConnectionManager), + instance(mock()), + instance(mock()), + fakeTreeView as unknown as TreeView + ); + + // Replace getValidRoot to capture rootPath and short-circuit execution + capturedRootPath = undefined; + (commands as any).getValidRoot = async (rootPath?: string) => { + capturedRootPath = rootPath; + return undefined; + }; + }); + + describe("createFolder", () => { + it("title bar, nothing selected → targets root", async () => { + await commands.createFolder(undefined); + assert.strictEqual(capturedRootPath, ROOT_PATH); + }); + + it("title bar, A selected → targets A", async () => { + fakeTreeView.simulateSelect(entityA); + await commands.createFolder(entityA); + assert.strictEqual(capturedRootPath, entityA.path); + }); + + it("title bar, A selected then deselected → targets root", async () => { + fakeTreeView.simulateSelect(entityA); + fakeTreeView.simulateSelect(undefined); + await commands.createFolder(undefined); + assert.strictEqual(capturedRootPath, ROOT_PATH); + }); + + it("context menu on B while A is selected → targets B", async () => { + fakeTreeView.simulateSelect(entityA); + // Right-click on B does NOT update selection; selection[0] is still A + await commands.createFolder(entityB); + assert.strictEqual(capturedRootPath, entityB.path); + }); + + it("context menu on B with nothing selected → targets B", async () => { + await commands.createFolder(entityB); + assert.strictEqual(capturedRootPath, entityB.path); + }); + + it("context menu on A while A is selected → targets A", async () => { + fakeTreeView.simulateSelect(entityA); + // Right-click on the already-selected item: element === selection[0] + await commands.createFolder(entityA); + assert.strictEqual(capturedRootPath, entityA.path); + }); + }); + + describe("uploadFile", () => { + // uploadFile bails early when workspaceClient is undefined, before + // reaching getValidRoot. Provide a non-null client so the activeElement + // logic is exercised. + beforeEach(() => { + when(mockConnectionManager.workspaceClient).thenReturn({} as any); + }); + + it("title bar, nothing selected → targets root", async () => { + await commands.uploadFile(undefined); + assert.strictEqual(capturedRootPath, ROOT_PATH); + }); + + it("title bar, A selected → targets A", async () => { + fakeTreeView.simulateSelect(entityA); + await commands.uploadFile(entityA); + assert.strictEqual(capturedRootPath, entityA.path); + }); + + it("title bar, A selected then deselected → targets root", async () => { + fakeTreeView.simulateSelect(entityA); + fakeTreeView.simulateSelect(undefined); + await commands.uploadFile(undefined); + assert.strictEqual(capturedRootPath, ROOT_PATH); + }); + + it("context menu on B while A is selected → targets B", async () => { + fakeTreeView.simulateSelect(entityA); + await commands.uploadFile(entityB); + assert.strictEqual(capturedRootPath, entityB.path); + }); + + it("context menu on B with nothing selected → targets B", async () => { + await commands.uploadFile(entityB); + assert.strictEqual(capturedRootPath, entityB.path); + }); + + it("context menu on A while A is selected → targets A", async () => { + fakeTreeView.simulateSelect(entityA); + await commands.uploadFile(entityA); + assert.strictEqual(capturedRootPath, entityA.path); + }); + + it("title bar, file (non-directory) selected → targets root", async () => { + const fileEntity = { + path: "/Users/me/A/note.py", + type: "FILE", + } as unknown as WorkspaceFsEntity; + fakeTreeView.simulateSelect(fileEntity); + await commands.uploadFile(fileEntity); + assert.strictEqual(capturedRootPath, ROOT_PATH); + }); + }); +}); diff --git a/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.ts b/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.ts index cbdc50f29..bc493646a 100644 --- a/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.ts +++ b/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.ts @@ -5,7 +5,7 @@ import { WorkspaceFsUtils, } from "../sdk-extensions"; import {context, Context} from "@databricks/sdk-experimental/dist/context"; -import {Disposable, Uri, env, window, workspace} from "vscode"; +import {Disposable, TreeView, Uri, env, window, workspace} from "vscode"; import {ConnectionManager} from "../configuration/ConnectionManager"; import {Loggers} from "../logger"; import {createDirWizard} from "./createDirectoryWizard"; @@ -18,13 +18,21 @@ const withLogContext = logging.withLogContext; export class WorkspaceFsCommands implements Disposable { private disposables: Disposable[] = []; + private selectedElement: WorkspaceFsEntity | undefined; constructor( private workspaceFolderManager: WorkspaceFolderManager, private connectionManager: ConnectionManager, private workspaceFsDataProvider: WorkspaceFsDataProvider, - private fsp: WorkspaceFsFileSystemProvider - ) {} + private fsp: WorkspaceFsFileSystemProvider, + private readonly treeView: TreeView + ) { + this.disposables.push( + treeView.onDidChangeSelection((e) => { + this.selectedElement = e.selection[0]; + }) + ); + } @withLogContext(Loggers.Extension) async getValidRoot( @@ -70,10 +78,19 @@ export class WorkspaceFsCommands implements Disposable { return root; } + private resolveTargetElement( + element?: WorkspaceFsEntity + ): WorkspaceFsEntity | undefined { + return element !== this.treeView.selection[0] + ? element + : this.selectedElement; + } + @withLogContext(Loggers.Extension) async createFolder(element?: WorkspaceFsEntity, @context ctx?: Context) { + const activeElement = this.resolveTargetElement(element); const rootPath = - element?.path ?? + activeElement?.path ?? this.connectionManager.databricksWorkspace?.currentFsRoot.path; const root = await this.getValidRoot(rootPath, ctx); @@ -179,9 +196,11 @@ export class WorkspaceFsCommands implements Disposable { return; } + const activeElement = this.resolveTargetElement(element); const rootPath = - (element?.type === "DIRECTORY" || element?.type === "REPO" - ? element?.path + (activeElement?.type === "DIRECTORY" || + activeElement?.type === "REPO" + ? activeElement?.path : undefined) ?? this.connectionManager.databricksWorkspace?.currentFsRoot.path;