From eb67d31da7880c4f2032ee6c222499aa24b0ba9d Mon Sep 17 00:00:00 2001 From: misha-db Date: Tue, 16 Jun 2026 19:37:20 +0400 Subject: [PATCH 1/2] fix: use correct target folder for createFolder/uploadFile in Workspace FS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extension.ts Switches the Workspace FS panel registration from window.registerTreeDataProvider to window.createTreeView so the TreeView handle (which exposes selection) is available. The resulting workspaceFsTreeView is passed to WorkspaceFsCommands and disposed via context.subscriptions. WorkspaceFsCommands.ts - Accepts TreeView as a new constructor parameter (private readonly treeView). - Tracks the last left-click–selected item in selectedElement via treeView.onDidChangeSelection (cleared to undefined on deselection). - In createFolder and uploadFile, resolves the target folder using a comparison: - If element !== treeView.selection[0] — the command was invoked from a context menu on a different item than the current selection → use element. - Otherwise — title bar button, or context menu on the already-selected item → use selectedElement (which is undefined if the user deselected before clicking, correctly falling back to the workspace root). This fixes two bugs: 1. Title bar click after deselection was targeting the previously selected folder instead of root. 2. Context-menu click on a non-selected item was targeting the selected folder instead of the right-clicked one. --- packages/databricks-vscode/src/extension.ts | 11 +++---- .../src/workspace-fs/WorkspaceFsCommands.ts | 29 +++++++++++++++---- 2 files changed, 29 insertions(+), 11 deletions(-) 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.ts b/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.ts index cbdc50f29..5a170e34d 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( @@ -72,8 +80,12 @@ export class WorkspaceFsCommands implements Disposable { @withLogContext(Loggers.Extension) async createFolder(element?: WorkspaceFsEntity, @context ctx?: Context) { + const activeElement = + element !== this.treeView.selection[0] + ? element + : this.selectedElement; const rootPath = - element?.path ?? + activeElement?.path ?? this.connectionManager.databricksWorkspace?.currentFsRoot.path; const root = await this.getValidRoot(rootPath, ctx); @@ -179,9 +191,14 @@ export class WorkspaceFsCommands implements Disposable { return; } + const activeElement = + element !== this.treeView.selection[0] + ? element + : this.selectedElement; const rootPath = - (element?.type === "DIRECTORY" || element?.type === "REPO" - ? element?.path + (activeElement?.type === "DIRECTORY" || + activeElement?.type === "REPO" + ? activeElement?.path : undefined) ?? this.connectionManager.databricksWorkspace?.currentFsRoot.path; From 5bdab7a3a3a204b98928f00010ee181d715447ef Mon Sep 17 00:00:00 2001 From: misha-db Date: Tue, 16 Jun 2026 20:13:14 +0400 Subject: [PATCH 2/2] Add tests, cleanup --- .../workspace-fs/WorkspaceFsCommands.test.ts | 166 ++++++++++++++++++ .../src/workspace-fs/WorkspaceFsCommands.ts | 18 +- 2 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.test.ts 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 5a170e34d..bc493646a 100644 --- a/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.ts +++ b/packages/databricks-vscode/src/workspace-fs/WorkspaceFsCommands.ts @@ -78,12 +78,17 @@ 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 = - element !== this.treeView.selection[0] - ? element - : this.selectedElement; + const activeElement = this.resolveTargetElement(element); const rootPath = activeElement?.path ?? this.connectionManager.databricksWorkspace?.currentFsRoot.path; @@ -191,10 +196,7 @@ export class WorkspaceFsCommands implements Disposable { return; } - const activeElement = - element !== this.treeView.selection[0] - ? element - : this.selectedElement; + const activeElement = this.resolveTargetElement(element); const rootPath = (activeElement?.type === "DIRECTORY" || activeElement?.type === "REPO"