diff --git a/extensions/data-workspace/src/common/constants.ts b/extensions/data-workspace/src/common/constants.ts index 5b65300e61..7784c2e760 100644 --- a/extensions/data-workspace/src/common/constants.ts +++ b/extensions/data-workspace/src/common/constants.ts @@ -15,7 +15,7 @@ export const AllProjectTypes = localize('AllProjectTypes', "All Project Types"); export const ProviderNotFoundForProjectTypeError = (projectType: string): string => { return localize('UnknownProjectTypeError', "No provider was found for project type with id: '{0}'", projectType); }; export const WorkspaceRequiredMessage = localize('dataworkspace.workspaceRequiredMessage', "A workspace is required in order to use the project feature."); export const OpenWorkspace = localize('dataworkspace.openWorkspace', "Open Workspace…"); -export const CreateWorkspaceConfirmation = localize('dataworkspace.createWorkspaceConfirmation', "A new workspace will be created and opened in order to open project. The Extension Host will restart and if there is a folder currently open, it will be closed."); +export const CreateWorkspaceConfirmation = localize('dataworkspace.createWorkspaceConfirmation', "A workspace will be created and opened in order to open project. The Extension Host will restart and if there is a folder currently open, it will be closed."); export const EnterWorkspaceConfirmation = localize('dataworkspace.enterWorkspaceConfirmation', "To open this workspace, the Extension Host will restart and if there is a workspace or folder currently open, it will be closed."); export const WorkspaceContainsNotAddedProjects = localize('dataworkspace.workspaceContainsNotAddedProjects', "The current workspace contains one or more projects that have not been added to the workspace. Use the 'Open existing' dialog to add projects to the projects pane."); export const LaunchOpenExisitingDialog = localize('dataworkspace.launchOpenExistingDialog', "Launch Open existing dialog"); @@ -41,7 +41,7 @@ export const ProjectNamePlaceholder = localize('dataworkspace.projectNamePlaceho export const ProjectLocationTitle = localize('dataworkspace.projectLocationTitle', "Location"); export const ProjectLocationPlaceholder = localize('dataworkspace.projectLocationPlaceholder', "Select location to create project"); export const AddProjectToCurrentWorkspace = localize('dataworkspace.AddProjectToCurrentWorkspace', "This project will be added to the current workspace."); -export const NewWorkspaceWillBeCreated = localize('dataworkspace.NewWorkspaceWillBeCreated', "A new workspace will be created for this project."); +export const NewWorkspaceWillBeCreated = localize('dataworkspace.NewWorkspaceWillBeCreated', "A workspace will be created for this project."); export const WorkspaceLocationTitle = localize('dataworkspace.workspaceLocationTitle', "Workspace location"); export const ProjectParentDirectoryNotExistError = (location: string): string => { return localize('dataworkspace.projectParentDirectoryNotExistError', "The selected project location '{0}' does not exist or is not a directory.", location); }; export const ProjectDirectoryAlreadyExistError = (projectName: string, location: string): string => { return localize('dataworkspace.projectDirectoryAlreadyExistError', "There is already a directory named '{0}' in the selected location: '{1}'.", projectName, location); }; diff --git a/extensions/data-workspace/src/common/utils.ts b/extensions/data-workspace/src/common/utils.ts index 2de021c1a1..a983b8e3b4 100644 --- a/extensions/data-workspace/src/common/utils.ts +++ b/extensions/data-workspace/src/common/utils.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as fs from 'fs'; +import * as vscode from 'vscode'; export async function directoryExist(directoryPath: string): Promise { const stats = await getFileStatus(directoryPath); @@ -30,6 +31,13 @@ async function getFileStatus(path: string): Promise { } } +/** + * if the current workspace is untitled, the returned URI of vscode.workspace.workspaceFile will use the `untitled` scheme + */ +export function isCurrentWorkspaceUntitled(): boolean { + return !!vscode.workspace.workspaceFile && vscode.workspace.workspaceFile.scheme.toLowerCase() === 'untitled'; +} + export interface IPackageInfo { name: string; version: string; diff --git a/extensions/data-workspace/src/dialogs/dialogBase.ts b/extensions/data-workspace/src/dialogs/dialogBase.ts index 9980641fd5..3dc52ce80a 100644 --- a/extensions/data-workspace/src/dialogs/dialogBase.ts +++ b/extensions/data-workspace/src/dialogs/dialogBase.ts @@ -8,7 +8,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import * as constants from '../common/constants'; import { IconPathHelper } from '../common/iconHelper'; -import { directoryExist, fileExist } from '../common/utils'; +import { directoryExist, fileExist, isCurrentWorkspaceUntitled } from '../common/utils'; interface Deferred { resolve: (result: T | Promise) => void; @@ -94,12 +94,14 @@ export abstract class DialogBase { CSSStyles: { 'margin-top': '3px', 'margin-bottom': '0px' } }).component(); + const initialWorkspaceInputBoxValue = !!vscode.workspace.workspaceFile && !isCurrentWorkspaceUntitled() ? vscode.workspace.workspaceFile.fsPath : ''; + this.workspaceInputBox = view.modelBuilder.inputBox().withProperties({ ariaLabel: constants.WorkspaceLocationTitle, width: constants.DefaultInputWidth, - enabled: !vscode.workspace.workspaceFile, // want it editable if no workspace is open - value: vscode.workspace.workspaceFile?.fsPath ?? '', - title: vscode.workspace.workspaceFile?.fsPath ?? '' // hovertext for if file path is too long to be seen in textbox + enabled: !vscode.workspace.workspaceFile || isCurrentWorkspaceUntitled(), // want it editable if no saved workspace is open + value: initialWorkspaceInputBoxValue, + title: initialWorkspaceInputBoxValue // hovertext for if file path is too long to be seen in textbox }).component(); const browseFolderButton = view.modelBuilder.button().withProperties({ @@ -129,7 +131,7 @@ export abstract class DialogBase { this.workspaceInputBox!.title = selectedFile; })); - if (vscode.workspace.workspaceFile) { + if (vscode.workspace.workspaceFile && !isCurrentWorkspaceUntitled()) { this.workspaceInputFormComponent = { component: this.workspaceInputBox }; @@ -154,7 +156,7 @@ export abstract class DialogBase { * @param name */ protected updateWorkspaceInputbox(location: string, name: string): void { - if (!vscode.workspace.workspaceFile) { + if (!vscode.workspace.workspaceFile || isCurrentWorkspaceUntitled()) { const fileLocation = location && name ? path.join(location, `${name}.code-workspace`) : ''; this.workspaceInputBox!.value = fileLocation; this.workspaceInputBox!.title = fileLocation; diff --git a/extensions/data-workspace/src/services/workspaceService.ts b/extensions/data-workspace/src/services/workspaceService.ts index 31cfb6baf4..18c53dd7fd 100644 --- a/extensions/data-workspace/src/services/workspaceService.ts +++ b/extensions/data-workspace/src/services/workspaceService.ts @@ -13,6 +13,7 @@ import { IWorkspaceService } from '../common/interfaces'; import { ProjectProviderRegistry } from '../common/projectProviderRegistry'; import Logger from '../common/logger'; import { TelemetryReporter, TelemetryViews, calculateRelativity, TelemetryActions } from '../common/telemetry'; +import { isCurrentWorkspaceUntitled } from '../common/utils'; const WorkspaceConfigurationName = 'dataworkspace'; const ProjectsConfigurationName = 'projects'; @@ -51,9 +52,15 @@ export class WorkspaceService implements IWorkspaceService { // save temp project await this._context.globalState.update(TempProject, [projectFileFsPath]); - // create a new workspace + // create workspace const projectFolder = vscode.Uri.file(path.dirname(projectFileFsPath)); - await azdata.workspace.createWorkspace(projectFolder, workspaceFile); + + if (isCurrentWorkspaceUntitled()) { + vscode.workspace.updateWorkspaceFolders(vscode.workspace.workspaceFolders!.length, null, { uri: projectFolder }); + await azdata.workspace.saveAndEnterWorkspace(workspaceFile!); + } else { + await azdata.workspace.createAndEnterWorkspace(projectFolder, workspaceFile); + } } get isProjectProviderAvailable(): boolean { @@ -67,10 +74,10 @@ export class WorkspaceService implements IWorkspaceService { } /** - * Verify that a workspace is open or that if one isn't, it's ok to create a workspace + * Verify that a workspace is open or that if one isn't, it's ok to create a workspace and restart the extension host */ async validateWorkspace(): Promise { - if (!vscode.workspace.workspaceFile) { + if (!vscode.workspace.workspaceFile || isCurrentWorkspaceUntitled()) { const result = await vscode.window.showWarningMessage(constants.CreateWorkspaceConfirmation, constants.OkButtonText, constants.CancelButtonText); if (result === constants.OkButtonText) { return true; @@ -102,7 +109,7 @@ export class WorkspaceService implements IWorkspaceService { } // a workspace needs to be open to add projects - if (!vscode.workspace.workspaceFile) { + if (!vscode.workspace.workspaceFile || isCurrentWorkspaceUntitled()) { await this.CreateNewWorkspaceForProject(projectFiles[0].fsPath, workspaceFilePath); // this won't get hit since the extension host will get restarted, but helps with testing diff --git a/extensions/data-workspace/src/test/workspaceService.test.ts b/extensions/data-workspace/src/test/workspaceService.test.ts index bc534b5dd6..b0087db88b 100644 --- a/extensions/data-workspace/src/test/workspaceService.test.ts +++ b/extensions/data-workspace/src/test/workspaceService.test.ts @@ -11,6 +11,7 @@ import * as should from 'should'; import * as path from 'path'; import * as TypeMoq from 'typemoq'; import * as constants from '../common/constants'; +import * as utils from '../common/utils'; import { WorkspaceService } from '../services/workspaceService'; import { ProjectProviderRegistry } from '../common/projectProviderRegistry'; import { createProjectProvider } from './projectProviderRegistry.test'; @@ -246,13 +247,32 @@ suite('WorkspaceService Tests', function (): void { const onWorkspaceProjectsChangedDisposable = service.onDidWorkspaceProjectsChange(() => { onWorkspaceProjectsChangedStub(); }); - const createWorkspaceStub = sinon.stub(azdata.workspace, 'createWorkspace').resolves(undefined); + const createWorkspaceStub = sinon.stub(azdata.workspace, 'createAndEnterWorkspace').resolves(undefined); await service.addProjectsToWorkspace([ vscode.Uri.file('/test/folder/proj1.sqlproj') ]); - should.strictEqual(createWorkspaceStub.calledOnce, true, 'createWorkspace should have been called once'); + should.strictEqual(createWorkspaceStub.calledOnce, true, 'createAndEnterWorkspace should have been called once'); + should.strictEqual(onWorkspaceProjectsChangedStub.notCalled, true, 'the onDidWorkspaceProjectsChange event should not have been fired'); + onWorkspaceProjectsChangedDisposable.dispose(); + }); + + test('test addProjectsToWorkspace when untitled workspace is open', async () => { + stubWorkspaceFile(undefined); + const onWorkspaceProjectsChangedStub = sinon.stub(); + const onWorkspaceProjectsChangedDisposable = service.onDidWorkspaceProjectsChange(() => { + onWorkspaceProjectsChangedStub(); + }); + const saveWorkspaceStub = sinon.stub(azdata.workspace, 'saveAndEnterWorkspace').resolves(undefined); + sinon.stub(utils, 'isCurrentWorkspaceUntitled').returns(true); + sinon.stub(vscode.workspace, 'workspaceFolders').value(['folder1']); + + await service.addProjectsToWorkspace([ + vscode.Uri.file('/test/folder/proj1.sqlproj') + ]); + + should.strictEqual(saveWorkspaceStub.calledOnce, true, 'saveAndEnterWorkspace should have been called once'); should.strictEqual(onWorkspaceProjectsChangedStub.notCalled, true, 'the onDidWorkspaceProjectsChange event should not have been fired'); onWorkspaceProjectsChangedDisposable.dispose(); }); @@ -269,7 +289,7 @@ suite('WorkspaceService Tests', function (): void { onWorkspaceProjectsChangedStub(); }); stubGetConfigurationValue(getConfigurationStub, updateConfigurationStub); - sinon.stub(azdata.workspace, 'createWorkspace').resolves(undefined); + sinon.stub(azdata.workspace, 'createAndEnterWorkspace').resolves(undefined); sinon.stub(vscode.workspace, 'workspaceFolders').value(['folder1']); mockGlobalState.setup(x => x.get(TypeMoq.It.isAny())).returns(() => [processPath('folder1/proj2.sqlproj')]); diff --git a/extensions/sql-database-projects/src/common/utils.ts b/extensions/sql-database-projects/src/common/utils.ts index a8d26d6509..55445e29b0 100644 --- a/extensions/sql-database-projects/src/common/utils.ts +++ b/extensions/sql-database-projects/src/common/utils.ts @@ -244,6 +244,13 @@ export function getDataWorkspaceExtensionApi(): dataworkspace.IExtension { return extension.exports; } +/** + * if the current workspace is untitled, the returned URI of vscode.workspace.workspaceFile will use the `untitled` scheme + */ +export function isCurrentWorkspaceUntitled(): boolean { + return !!vscode.workspace.workspaceFile && vscode.workspace.workspaceFile.scheme.toLowerCase() === 'untitled'; +} + /* * Returns the default deployment options from DacFx */ diff --git a/extensions/sql-database-projects/src/dialogs/createProjectFromDatabaseDialog.ts b/extensions/sql-database-projects/src/dialogs/createProjectFromDatabaseDialog.ts index 6ee4a50d98..a1b4023c30 100644 --- a/extensions/sql-database-projects/src/dialogs/createProjectFromDatabaseDialog.ts +++ b/extensions/sql-database-projects/src/dialogs/createProjectFromDatabaseDialog.ts @@ -15,7 +15,7 @@ import { cssStyles } from '../common/uiConstants'; import { ImportDataModel } from '../models/api/import'; import { Deferred } from '../common/promise'; import { getConnectionName } from './utils'; -import { exists } from '../common/utils'; +import { exists, isCurrentWorkspaceUntitled } from '../common/utils'; export class CreateProjectFromDatabaseDialog { public dialog: azdata.window.Dialog; @@ -358,11 +358,13 @@ export class CreateProjectFromDatabaseDialog { * @param view */ private createWorkspaceContainerRow(view: azdata.ModelView): azdata.FlexContainer { + const initialWorkspaceInputBoxValue = !!vscode.workspace.workspaceFile && !isCurrentWorkspaceUntitled() ? vscode.workspace.workspaceFile.fsPath : ''; + this.workspaceInputBox = view.modelBuilder.inputBox().withProperties({ ariaLabel: constants.workspaceLocationTitle, - enabled: !vscode.workspace.workspaceFile, // want it editable if no workspace is open - value: vscode.workspace.workspaceFile?.fsPath ?? '', - title: vscode.workspace.workspaceFile?.fsPath ?? '', // hovertext for if file path is too long to be seen in textbox + enabled: !vscode.workspace.workspaceFile || isCurrentWorkspaceUntitled(), // want it editable if no saved workspace is open + value: initialWorkspaceInputBoxValue, + title: initialWorkspaceInputBoxValue, // hovertext for if file path is too long to be seen in textbox width: '100%' }).component(); @@ -399,9 +401,8 @@ export class CreateProjectFromDatabaseDialog { }).component(); let workspaceContainerRow; - if (vscode.workspace.workspaceFile) { + if (vscode.workspace.workspaceFile && !isCurrentWorkspaceUntitled()) { workspaceContainerRow = view.modelBuilder.flexContainer().withItems([workspaceLabel, this.workspaceInputBox], { flex: '0 0 auto', CSSStyles: { 'margin-right': '10px', 'margin-top': '0px' } }).withLayout({ flexFlow: 'column' }).component(); - } else { // have browse button to help select where the workspace file should be created const workspaceInput = view.modelBuilder.flexContainer().withItems([this.workspaceInputBox], { CSSStyles: { 'margin-right': '10px', 'margin-bottom': '10px', 'width': '100%' } }).withLayout({ flexFlow: 'row', alignItems: 'center' }).component(); @@ -418,7 +419,7 @@ export class CreateProjectFromDatabaseDialog { * @param name */ public updateWorkspaceInputbox(location: string, name: string): void { - if (!vscode.workspace.workspaceFile) { + if (!vscode.workspace.workspaceFile || isCurrentWorkspaceUntitled()) { const fileLocation = location && name ? path.join(location, `${name}.code-workspace`) : ''; this.workspaceInputBox!.value = fileLocation; this.workspaceInputBox!.title = fileLocation; diff --git a/src/sql/azdata.proposed.d.ts b/src/sql/azdata.proposed.d.ts index 2d4aa51441..d71bc95b0d 100644 --- a/src/sql/azdata.proposed.d.ts +++ b/src/sql/azdata.proposed.d.ts @@ -944,13 +944,19 @@ declare module 'azdata' { /** * Creates and enters a workspace at the specified location */ - export function createWorkspace(location: vscode.Uri, workspaceFile?: vscode.Uri): Promise; + export function createAndEnterWorkspace(location: vscode.Uri, workspaceFile?: vscode.Uri): Promise; /** * Enters the workspace with the provided path * @param workspacefile */ export function enterWorkspace(workspaceFile: vscode.Uri): Promise; + + /** + * Saves and enters the workspace with the provided path + * @param workspacefile + */ + export function saveAndEnterWorkspace(workspaceFile: vscode.Uri): Promise; } export interface TableComponentProperties { diff --git a/src/sql/workbench/api/browser/mainThreadWorkspace.ts b/src/sql/workbench/api/browser/mainThreadWorkspace.ts index e881878153..22bcfb92ff 100644 --- a/src/sql/workbench/api/browser/mainThreadWorkspace.ts +++ b/src/sql/workbench/api/browser/mainThreadWorkspace.ts @@ -9,18 +9,20 @@ import { extHostNamedCustomer } from 'vs/workbench/api/common/extHostCustomers'; import { Disposable } from 'vs/base/common/lifecycle'; import { URI } from 'vs/base/common/uri'; import { IWorkspaceEditingService } from 'vs/workbench/services/workspaces/common/workspaceEditing'; +import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; @extHostNamedCustomer(SqlMainContext.MainThreadWorkspace) export class MainThreadWorkspace extends Disposable implements MainThreadWorkspaceShape { constructor( extHostContext: IExtHostContext, - @IWorkspaceEditingService private workspaceEditingService: IWorkspaceEditingService + @IWorkspaceEditingService private workspaceEditingService: IWorkspaceEditingService, + @IWorkbenchEnvironmentService protected readonly environmentService: IWorkbenchEnvironmentService, ) { super(); } - $createWorkspace(folder: URI, workspaceFile?: URI): Promise { + $createAndEnterWorkspace(folder: URI, workspaceFile?: URI): Promise { folder = URI.revive(folder); workspaceFile = URI.revive(workspaceFile); return this.workspaceEditingService.createAndEnterWorkspace([{ uri: folder }], workspaceFile); @@ -30,4 +32,10 @@ export class MainThreadWorkspace extends Disposable implements MainThreadWorkspa workspaceFile = URI.revive(workspaceFile); return this.workspaceEditingService.enterWorkspace(workspaceFile); } + + $saveAndEnterWorkspace(workspaceFile: URI): Promise { + workspaceFile = URI.revive(workspaceFile); + return this.workspaceEditingService.saveAndEnterWorkspace(workspaceFile); + } + } diff --git a/src/sql/workbench/api/common/extHostWorkspace.ts b/src/sql/workbench/api/common/extHostWorkspace.ts index 73edca540f..6f6119f2be 100644 --- a/src/sql/workbench/api/common/extHostWorkspace.ts +++ b/src/sql/workbench/api/common/extHostWorkspace.ts @@ -17,11 +17,15 @@ export class ExtHostWorkspace implements ExtHostWorkspaceShape { this._proxy = _mainContext.getProxy(SqlMainContext.MainThreadWorkspace); } - $createWorkspace(folder: URI, workspaceFile: URI): Promise { - return this._proxy.$createWorkspace(folder, workspaceFile); + $createAndEnterWorkspace(folder: URI, workspaceFile: URI): Promise { + return this._proxy.$createAndEnterWorkspace(folder, workspaceFile); } $enterWorkspace(workspaceFile: URI): Promise { return this._proxy.$enterWorkspace(workspaceFile); } + + $saveAndEnterWorkspace(workspaceFile: URI): Promise { + return this._proxy.$saveAndEnterWorkspace(workspaceFile); + } } diff --git a/src/sql/workbench/api/common/sqlExtHost.api.impl.ts b/src/sql/workbench/api/common/sqlExtHost.api.impl.ts index 2ac3b62845..3798048e76 100644 --- a/src/sql/workbench/api/common/sqlExtHost.api.impl.ts +++ b/src/sql/workbench/api/common/sqlExtHost.api.impl.ts @@ -467,11 +467,14 @@ export function createAdsApiFactory(accessor: ServicesAccessor): IAdsExtensionAp createModelViewEditor(title: string, options?: azdata.ModelViewEditorOptions, name?: string): azdata.workspace.ModelViewEditor { return extHostModelViewDialog.createModelViewEditor(title, extension, name, options); }, - createWorkspace(location: vscode.Uri, workspaceFile: vscode.Uri): Promise { - return extHostWorkspace.$createWorkspace(location, workspaceFile); + createAndEnterWorkspace(location: vscode.Uri, workspaceFile: vscode.Uri): Promise { + return extHostWorkspace.$createAndEnterWorkspace(location, workspaceFile); }, enterWorkspace(workspaceFile: vscode.Uri): Promise { return extHostWorkspace.$enterWorkspace(workspaceFile); + }, + saveAndEnterWorkspace(workspaceFile: vscode.Uri): Promise { + return extHostWorkspace.$saveAndEnterWorkspace(workspaceFile); } }; diff --git a/src/sql/workbench/api/common/sqlExtHost.protocol.ts b/src/sql/workbench/api/common/sqlExtHost.protocol.ts index a493af0dc4..bfc6e305a0 100644 --- a/src/sql/workbench/api/common/sqlExtHost.protocol.ts +++ b/src/sql/workbench/api/common/sqlExtHost.protocol.ts @@ -759,13 +759,15 @@ export interface ExtHostBackgroundTaskManagementShape { } export interface ExtHostWorkspaceShape { - $createWorkspace(folder: vscode.Uri, workspaceFile: vscode.Uri): Promise; + $createAndEnterWorkspace(folder: vscode.Uri, workspaceFile: vscode.Uri): Promise; $enterWorkspace(workspaceFile: vscode.Uri): Promise; + $saveAndEnterWorkspace(workspaceFile: vscode.Uri): Promise; } export interface MainThreadWorkspaceShape { - $createWorkspace(folder: vscode.Uri, workspaceFile: vscode.Uri): Promise; + $createAndEnterWorkspace(folder: vscode.Uri, workspaceFile: vscode.Uri): Promise; $enterWorkspace(workspaceFile: vscode.Uri): Promise; + $saveAndEnterWorkspace(workspaceFile: vscode.Uri): Promise; } export interface MainThreadBackgroundTaskManagementShape extends IDisposable {