diff --git a/extensions/data-workspace/src/common/constants.ts b/extensions/data-workspace/src/common/constants.ts index dfa7979cad..c524368379 100644 --- a/extensions/data-workspace/src/common/constants.ts +++ b/extensions/data-workspace/src/common/constants.ts @@ -20,10 +20,12 @@ export const EnterWorkspaceConfirmation = localize('dataworkspace.enterWorkspace 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"); export const DoNotShowAgain = localize('dataworkspace.doNotShowAgain', "Do not show again"); -export const ProjectsFailedToLoad = localize('dataworkspace.projectsFailedToLoad', "Some projects failed to load. Please open console for more information"); +export const ProjectsFailedToLoad = localize('dataworkspace.projectsFailedToLoad', "Some projects failed to load. To view more details, [open the developer console](command:workbench.action.toggleDevTools)"); export const fileDoesNotExist = (name: string): string => { return localize('fileDoesNotExist', "File '{0}' doesn't exist", name); }; export const projectNameNull = localize('projectNameNull', "Project name is null"); export const noPreviousData = (tableName: string): string => { return localize('noPreviousData', "Prior {0} for the current project will appear here, please run to see the results.", tableName); }; +export const gitCloneMessage = (url: string): string => { return localize('gitCloneMessage', "Cloning git repository '{0}'...", url); }; +export const gitCloneError = localize('gitCloneError', "Error during git clone. View git output for more details"); // config settings export const projectsConfigurationKey = 'projects'; diff --git a/extensions/data-workspace/src/common/interfaces.ts b/extensions/data-workspace/src/common/interfaces.ts index 3eea7c4ad6..9611169f90 100644 --- a/extensions/data-workspace/src/common/interfaces.ts +++ b/extensions/data-workspace/src/common/interfaces.ts @@ -81,6 +81,14 @@ export interface IWorkspaceService { */ createProject(name: string, location: vscode.Uri, projectTypeId: string, workspaceFile?: vscode.Uri): Promise; + /** + * Clones git repository and adds projects to workspace + * @param url The url to clone from + * @param localClonePath local path to clone repository to + * @param workspaceFile workspace file to add the projects to + */ + gitCloneProject(url: string, localClonePath: string, workspaceFile: vscode.Uri): Promise; + readonly isProjectProviderAvailable: boolean; /** diff --git a/extensions/data-workspace/src/dialogs/openExistingDialog.ts b/extensions/data-workspace/src/dialogs/openExistingDialog.ts index 327581c377..684f2e081d 100644 --- a/extensions/data-workspace/src/dialogs/openExistingDialog.ts +++ b/extensions/data-workspace/src/dialogs/openExistingDialog.ts @@ -49,7 +49,11 @@ export class OpenExistingDialog extends DialogBase { try { // the selected location should be an existing directory if (this.targetTypeRadioCardGroup?.selectedCardId === constants.Project) { - await this.validateFile(this.filePathTextBox!.value!, constants.Project.toLowerCase()); + if (this.localRadioButton?.checked) { + await this.validateFile(this.filePathTextBox!.value!, constants.Project.toLowerCase()); + } else { + await this.validateClonePath(this.localClonePathTextBox!.value); + } if (this.workspaceInputBox!.enabled) { await this.validateNewWorkspace(false); @@ -101,6 +105,8 @@ export class OpenExistingDialog extends DialogBase { .withAdditionalProperties({ selectedTarget: 'workspace' }) .send(); + // show git output channel + vscode.commands.executeCommand('git.showOutput'); // after this executes, the git extension will show a popup asking if you want to enter the workspace await vscode.commands.executeCommand('git.clone', (this.gitRepoTextBoxComponent?.component).value, this.localClonePathTextBox!.value); } else { @@ -113,14 +119,22 @@ export class OpenExistingDialog extends DialogBase { const validateWorkspace = await this.workspaceService.validateWorkspace(); let addProjectsPromise: Promise; - if (validateWorkspace) { - telemetryProps.workspaceProjectRelativity = calculateRelativity(this.filePathTextBox!.value!, this.workspaceInputBox!.value!); - telemetryProps.cancelled = 'false'; - addProjectsPromise = this.workspaceService.addProjectsToWorkspace([vscode.Uri.file(this.filePathTextBox!.value!)], vscode.Uri.file(this.workspaceInputBox!.value!)); + if (this.remoteGitRepoRadioButton!.checked) { + TelemetryReporter.createActionEvent(TelemetryViews.OpenExistingDialog, TelemetryActions.GitClone) + .withAdditionalProperties({ selectedTarget: 'project' }) + .send(); + + addProjectsPromise = this.workspaceService.gitCloneProject((this.gitRepoTextBoxComponent?.component).value!, this.localClonePathTextBox!.value!, vscode.Uri.file(this.workspaceInputBox!.value!)); } else { - telemetryProps.workspaceProjectRelativity = 'none'; - telemetryProps.cancelled = 'true'; - addProjectsPromise = this.workspaceService.addProjectsToWorkspace([vscode.Uri.file(this.filePathTextBox!.value!)], vscode.Uri.file(this.workspaceInputBox!.value!)); + if (validateWorkspace) { + telemetryProps.workspaceProjectRelativity = calculateRelativity(this.filePathTextBox!.value!, this.workspaceInputBox!.value!); + telemetryProps.cancelled = 'false'; + addProjectsPromise = this.workspaceService.addProjectsToWorkspace([vscode.Uri.file(this.filePathTextBox!.value!)], vscode.Uri.file(this.workspaceInputBox!.value!)); + } else { + telemetryProps.workspaceProjectRelativity = 'none'; + telemetryProps.cancelled = 'true'; + addProjectsPromise = this.workspaceService.addProjectsToWorkspace([vscode.Uri.file(this.filePathTextBox!.value!)], vscode.Uri.file(this.workspaceInputBox!.value!)); + } } TelemetryReporter.createActionEvent(TelemetryViews.OpenExistingDialog, TelemetryActions.OpeningProject) @@ -198,14 +212,21 @@ export class OpenExistingDialog extends DialogBase { } })); + const gitRepoTextBox = view.modelBuilder.inputBox().withProperties({ + ariaLabel: constants.GitRepoUrlTitle, + placeHolder: constants.GitRepoUrlPlaceholder, + required: true, + width: constants.DefaultInputWidth + }).component(); + + this.register(gitRepoTextBox.onTextChanged(() => { + gitRepoTextBox.updateProperty('title', this.localClonePathTextBox!.value!); + this.updateWorkspaceInputbox(this.localClonePathTextBox!.value!, path.basename(gitRepoTextBox!.value!, '.git')); + })); + this.gitRepoTextBoxComponent = { title: constants.GitRepoUrlTitle, - component: view.modelBuilder.inputBox().withProperties({ - ariaLabel: constants.GitRepoUrlTitle, - placeHolder: constants.GitRepoUrlPlaceholder, - required: true, - width: constants.DefaultInputWidth - }).component() + component: gitRepoTextBox }; this.localClonePathTextBox = view.modelBuilder.inputBox().withProperties({ @@ -215,6 +236,11 @@ export class OpenExistingDialog extends DialogBase { width: constants.DefaultInputWidth }).component(); + this.register(this.localClonePathTextBox.onTextChanged(() => { + this.localClonePathTextBox!.updateProperty('title', this.localClonePathTextBox!.value!); + this.updateWorkspaceInputbox(this.localClonePathTextBox!.value!, path.basename(gitRepoTextBox!.value!, '.git')); + })); + const localClonePathBrowseFolderButton = view.modelBuilder.button().withProperties({ ariaLabel: constants.BrowseButtonText, iconPath: IconPathHelper.folder, @@ -235,6 +261,8 @@ export class OpenExistingDialog extends DialogBase { const selectedFolder = folderUris[0].fsPath; this.localClonePathTextBox!.value = selectedFolder; + this.localClonePathTextBox!.updateProperty('title', this.localClonePathTextBox!.value); + this.updateWorkspaceInputbox(path.dirname(this.localClonePathTextBox!.value!), path.basename((this.gitRepoTextBoxComponent?.component)!.value!, '.git')); })); this.localClonePathComponent = { @@ -278,19 +306,21 @@ export class OpenExistingDialog extends DialogBase { this.register(this.targetTypeRadioCardGroup.onSelectionChanged(({ cardId }) => { if (cardId === constants.Project) { this.filePathTextBox!.placeHolder = constants.ProjectFilePlaceholder; - // hide these two radio buttons for now since git clone is just for workspaces - this.localRadioButton?.updateCssStyles({ 'display': 'none' }); - this.remoteGitRepoRadioButton?.updateCssStyles({ 'display': 'none' }); - this.formBuilder?.removeFormItem(this.gitRepoTextBoxComponent); - this.formBuilder?.removeFormItem(this.localClonePathComponent); - this.formBuilder?.addFormItem(this.filePathAndButtonComponent!); + if (this.remoteGitRepoRadioButton!.checked) { + this.formBuilder?.removeFormItem(this.filePathAndButtonComponent); + this.formBuilder?.insertFormItem(this.gitRepoTextBoxComponent, 2); + this.formBuilder?.insertFormItem(this.localClonePathComponent, 3); + } else { + this.formBuilder?.removeFormItem(this.gitRepoTextBoxComponent); + this.formBuilder?.removeFormItem(this.localClonePathComponent); + this.formBuilder?.addFormItem(this.filePathAndButtonComponent!); + } + this.formBuilder?.addFormItem(this.workspaceDescriptionFormComponent!); this.formBuilder?.addFormItem(this.workspaceInputFormComponent!); } else if (cardId === constants.Workspace) { this.filePathTextBox!.placeHolder = constants.WorkspacePlaceholder; - this.localRadioButton?.updateCssStyles({ 'display': 'block' }); - this.remoteGitRepoRadioButton?.updateCssStyles({ 'display': 'block' }); this.formBuilder?.removeFormItem(this.workspaceDescriptionFormComponent!); this.formBuilder?.removeFormItem(this.workspaceInputFormComponent!); diff --git a/extensions/data-workspace/src/services/workspaceService.ts b/extensions/data-workspace/src/services/workspaceService.ts index bebdde5c7e..dc700e8213 100644 --- a/extensions/data-workspace/src/services/workspaceService.ts +++ b/extensions/data-workspace/src/services/workspaceService.ts @@ -7,6 +7,7 @@ import * as azdata from 'azdata'; import * as vscode from 'vscode'; import * as dataworkspace from 'dataworkspace'; import * as path from 'path'; +import * as git from '../../../git/src/api/git'; import * as constants from '../common/constants'; import * as glob from 'fast-glob'; import { IWorkspaceService } from '../common/interfaces'; @@ -193,7 +194,7 @@ export class WorkspaceService implements IWorkspaceService { } for (const folder of workspaceFolders) { - const results = await this.getAllProjectsInWorkspaceFolder(folder); + const results = await this.getAllProjectsInFolder(folder.uri); let containsNotAddedProject = false; for (const projFile of results) { @@ -218,12 +219,12 @@ export class WorkspaceService implements IWorkspaceService { } } - async getAllProjectsInWorkspaceFolder(folder: vscode.WorkspaceFolder): Promise { + async getAllProjectsInFolder(folder: vscode.Uri): Promise { // get the unique supported project extensions const supportedProjectExtensions = [...new Set((await this.getAllProjectTypes()).map(p => { return p.projectFileExtension; }))]; // path needs to use forward slashes for glob to work - const escapedPath = glob.escapePath(folder.uri.fsPath.replace(/\\/g, '/')); + const escapedPath = glob.escapePath(folder.fsPath.replace(/\\/g, '/')); // can filter for multiple file extensions using folder/**/*.{sqlproj,csproj} format, but this notation doesn't work if there's only one extension // so the filter needs to be in the format folder/**/*.sqlproj if there's only one supported projectextension @@ -271,6 +272,31 @@ export class WorkspaceService implements IWorkspaceService { } } + async gitCloneProject(url: string, localClonePath: string, workspaceFile: vscode.Uri): Promise { + const gitApi: git.API = (vscode.extensions.getExtension('vscode.git')!.exports).getAPI(1); + const opts = { + location: vscode.ProgressLocation.Notification, + title: constants.gitCloneMessage(url), + cancellable: true + }; + + try { + // show git output channel + vscode.commands.executeCommand('git.showOutput'); + const repositoryPath = await vscode.window.withProgress( + opts, + (progress, token) => gitApi.clone(url!, { parentPath: localClonePath!, progress, recursive: true }, token) + ); + + // get all the project files in the cloned repo and add them to workspace + const repoProjects = (await this.getAllProjectsInFolder(vscode.Uri.file(repositoryPath))).map(p => { return vscode.Uri.file(p); }); + this.addProjectsToWorkspace(repoProjects, workspaceFile); + } catch (e) { + vscode.window.showErrorMessage(constants.gitCloneError); + console.error(e); + } + } + /** * Ensure the project provider extension for the specified project is loaded * @param projectType The file extension of the project, if not specified, all project provider extensions will be loaded. diff --git a/extensions/data-workspace/src/test/workspaceService.test.ts b/extensions/data-workspace/src/test/workspaceService.test.ts index 904ee68054..e4b7453d17 100644 --- a/extensions/data-workspace/src/test/workspaceService.test.ts +++ b/extensions/data-workspace/src/test/workspaceService.test.ts @@ -428,7 +428,7 @@ suite('WorkspaceService Tests', function (): void { icon: '' }]); const infoMessageStub = sinon.stub(vscode.window, 'showInformationMessage').resolves(constants.DoNotShowAgain); - const getProjectsInwWorkspaceFolderStub = sinon.stub(service, 'getAllProjectsInWorkspaceFolder').resolves([vscode.Uri.file('abc.sqlproj').fsPath, vscode.Uri.file('folder1/abc1.sqlproj').fsPath]); + const getProjectsInwWorkspaceFolderStub = sinon.stub(service, 'getAllProjectsInFolder').resolves([vscode.Uri.file('abc.sqlproj').fsPath, vscode.Uri.file('folder1/abc1.sqlproj').fsPath]); await service.checkForProjectsNotAddedToWorkspace(); should(infoMessageStub.notCalled).be.true('Should not have found projects not added to workspace'); diff --git a/extensions/git/src/api/api1.ts b/extensions/git/src/api/api1.ts index be91c429b2..58366b5a5d 100644 --- a/extensions/git/src/api/api1.ts +++ b/extensions/git/src/api/api1.ts @@ -5,8 +5,8 @@ import { Model } from '../model'; import { Repository as BaseRepository, Resource } from '../repository'; -import { InputBox, Git, API, Repository, Remote, RepositoryState, Branch, ForcePushMode, Ref, Submodule, Commit, Change, RepositoryUIState, Status, LogOptions, APIState, CommitOptions, RefType, RemoteSourceProvider, CredentialsProvider, BranchQuery, PushErrorHandler, PublishEvent } from './git'; -import { Event, SourceControlInputBox, Uri, SourceControl, Disposable, commands } from 'vscode'; +import { InputBox, Git, API, Repository, Remote, RepositoryState, Branch, ForcePushMode, Ref, Submodule, Commit, Change, RepositoryUIState, Status, LogOptions, APIState, CommitOptions, RefType, RemoteSourceProvider, CredentialsProvider, BranchQuery, PushErrorHandler, PublishEvent, ICloneOptions } from './git'; // {{SQL CARBON EDIT}} add ICloneOptions +import { Event, SourceControlInputBox, Uri, SourceControl, Disposable, commands, CancellationToken } from 'vscode'; // {{SQL CARBON EDIT}} add CancellationToken import { mapEvent } from '../util'; import { toGitUri } from '../uri'; import { pickRemoteSource, PickRemoteSourceOptions } from '../remoteSource'; @@ -253,6 +253,11 @@ export class ApiImpl implements API { return this._model.repositories.map(r => new ApiRepository(r)); } + // {{SQL CARBON EDIT}} + async clone(url: string, options: ICloneOptions, cancellationToken?: CancellationToken): Promise { + return this._model.git.clone(url, options, cancellationToken); + } + toGitUri(uri: Uri, ref: string): Uri { return toGitUri(uri, ref); } diff --git a/extensions/git/src/api/git.d.ts b/extensions/git/src/api/git.d.ts index 547bf7d132..45ab768865 100644 --- a/extensions/git/src/api/git.d.ts +++ b/extensions/git/src/api/git.d.ts @@ -3,7 +3,7 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Uri, Event, Disposable, ProviderResult } from 'vscode'; +import { Uri, Event, Disposable, ProviderResult, CancellationToken, Progress } from 'vscode'; // {{SQL CARBON EDIT}} add CancellationToken export { ProviderResult } from 'vscode'; export interface Git { @@ -251,6 +251,14 @@ export interface API { readonly onDidOpenRepository: Event; readonly onDidCloseRepository: Event; + /** + * clones the repository at the specified url locally + * @param url url of repository to clone + * @param options + * @param cancellationToken + * @returns a promise to the string location where the repository was cloned to + */ + clone(url: string, options: ICloneOptions, cancellationToken?: CancellationToken): Promise; // {{SQL CARBON EDIT}} toGitUri(uri: Uri, ref: string): Uri; getRepository(uri: Uri): Repository | null; init(root: Uri): Promise; @@ -316,3 +324,10 @@ export const enum GitErrorCodes { NoPathFound = 'NoPathFound', UnknownPath = 'UnknownPath', } + +// {{SQL CARBON EDIT}} move ICloneOptions from git.ts to here since it's used in clone() +export interface ICloneOptions { + readonly parentPath: string; + readonly progress: Progress<{ increment: number }>; + readonly recursive?: boolean; +} diff --git a/extensions/git/src/git.ts b/extensions/git/src/git.ts index 60ca0d70b4..121a6e0afd 100644 --- a/extensions/git/src/git.ts +++ b/extensions/git/src/git.ts @@ -12,9 +12,9 @@ import { EventEmitter } from 'events'; import * as iconv from 'iconv-lite-umd'; import * as filetype from 'file-type'; import { assign, groupBy, IDisposable, toDisposable, dispose, mkdirp, readBytes, detectUnicodeEncoding, Encoding, onceEvent, splitInChunks, Limiter } from './util'; -import { CancellationToken, Progress, Uri } from 'vscode'; +import { CancellationToken, Uri } from 'vscode'; import { detectEncoding } from './encoding'; -import { Ref, RefType, Branch, Remote, ForcePushMode, GitErrorCodes, LogOptions, Change, Status, CommitOptions, BranchQuery } from './api/git'; +import { Ref, RefType, Branch, Remote, ForcePushMode, GitErrorCodes, LogOptions, Change, Status, CommitOptions, BranchQuery, ICloneOptions } from './api/git'; // {{SQL CARBON EDIT}} add ICloneOptions import * as byline from 'byline'; import { StringDecoder } from 'string_decoder'; @@ -354,11 +354,11 @@ function sanitizePath(path: string): string { const COMMIT_FORMAT = '%H%n%aN%n%aE%n%at%n%ct%n%P%n%B'; -export interface ICloneOptions { +/*export interface ICloneOptions { {{SQL CARBON EDIT}} moved to git.d.ts readonly parentPath: string; readonly progress: Progress<{ increment: number }>; readonly recursive?: boolean; -} +}*/ export class Git {