From ddce5b1544ac62e650878cb514ecfbd176068e74 Mon Sep 17 00:00:00 2001 From: Matt Seddon <37993418+mattseddon@users.noreply.github.com> Date: Fri, 19 May 2023 16:05:43 +1000 Subject: [PATCH] Add command to add remote (#3929) * add add remote command * add tests * refactor * apply review feedback --- .eslintrc.js | 4 +- demo | 2 +- extension/package.json | 10 ++ extension/src/cli/dvc/constants.ts | 5 +- extension/src/cli/dvc/executor.ts | 7 +- extension/src/commands/external.ts | 2 + extension/src/extension.ts | 2 +- extension/src/setup/commands/index.ts | 86 ++++++++++++++++ .../src/setup/{ => commands}/register.ts | 21 ++-- extension/src/setup/webview/messages.ts | 2 + extension/src/telemetry/constants.ts | 2 + extension/src/test/suite/setup/index.test.ts | 98 +++++++++++++++++++ extension/src/test/suite/setup/util.ts | 3 +- extension/src/vscode/title.ts | 5 +- extension/src/webview/contract.ts | 2 + extension/src/workspace/index.ts | 14 +-- extension/src/workspace/util.ts | 14 +++ scripts/virtualenv-install.ts | 1 + webview/src/setup/components/App.test.tsx | 28 +++++- webview/src/setup/components/messages.ts | 3 + .../src/setup/components/remote/Connect.tsx | 3 + webview/src/stories/Setup.stories.tsx | 13 +++ 22 files changed, 297 insertions(+), 30 deletions(-) create mode 100644 extension/src/setup/commands/index.ts rename extension/src/setup/{ => commands}/register.ts (82%) create mode 100644 extension/src/workspace/util.ts diff --git a/.eslintrc.js b/.eslintrc.js index 1a67e932df..ca685b45e1 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -3,6 +3,7 @@ module.exports = { env: { 'jest/globals': true }, + extends: [ 'prettier-standard/prettier-file', 'plugin:@typescript-eslint/eslint-recommended', @@ -22,7 +23,8 @@ module.exports = { '**/*.js', '*.d.ts', 'tsconfig.json', - 'webpack.config.ts' + 'webpack.config.ts', + 'scripts/virtualenv-install.ts' ], overrides: [ { diff --git a/demo b/demo index a20953baeb..5f06c3734d 160000 --- a/demo +++ b/demo @@ -1 +1 @@ -Subproject commit a20953baeb985bdfa41490d220da32942345864f +Subproject commit 5f06c3734d76cb7ca894895e89d6d06dd878f8c4 diff --git a/extension/package.json b/extension/package.json index 9b611999b5..187fd9afa3 100644 --- a/extension/package.json +++ b/extension/package.json @@ -100,6 +100,12 @@ "category": "DVC", "icon": "$(add)" }, + { + "title": "Add Remote", + "command": "dvc.addRemote", + "category": "DVC", + "icon": "$(add)" + }, { "title": "Filter Experiments Table to Starred", "command": "dvc.addStarredExperimentsTableFilter", @@ -654,6 +660,10 @@ "command": "dvc.addExperimentsTableSort", "when": "dvc.commands.available && dvc.project.available" }, + { + "command": "dvc.addRemote", + "when": "dvc.commands.available && dvc.project.available && !dvc.experiment.running.workspace" + }, { "command": "dvc.addStarredExperimentsTableFilter", "when": "dvc.commands.available && dvc.project.available" diff --git a/extension/src/cli/dvc/constants.ts b/extension/src/cli/dvc/constants.ts index 2759f0ff5a..f2c03f7716 100644 --- a/extension/src/cli/dvc/constants.ts +++ b/extension/src/cli/dvc/constants.ts @@ -38,6 +38,7 @@ export enum Command { } export enum SubCommand { + ADD = 'add', DIFF = 'diff', LIST = 'list', STATUS = 'status', @@ -47,13 +48,15 @@ export enum SubCommand { export enum Flag { ALL_COMMITS = '-A', FOLLOW = '-f', + DEFAULT = '-d', FORCE = '-f', GLOBAL = '--global', GRANULAR = '--granular', - LOCAL = '--local', JOBS = '-j', JSON = '--json', KILL = '--kill', + LOCAL = '--local', + PROJECT = '--project', NUM_COMMIT = '-n', OUTPUT_PATH = '-o', SUBDIRECTORY = '--subdir', diff --git a/extension/src/cli/dvc/executor.ts b/extension/src/cli/dvc/executor.ts index 8136837ab5..c0a9a2ef50 100644 --- a/extension/src/cli/dvc/executor.ts +++ b/extension/src/cli/dvc/executor.ts @@ -7,8 +7,7 @@ import { ExperimentSubCommand, Flag, GcPreserveFlag, - QueueSubCommand, - SubCommand + QueueSubCommand } from './constants' import { addStudioAccessToken } from './options' import { CliResult, CliStarted, typeCheckCommands } from '..' @@ -198,8 +197,8 @@ export class DvcExecutor extends DvcCli { return this.blockAndExecuteProcess(cwd, Command.REMOVE, ...args) } - public remote(cwd: string, arg: typeof SubCommand.LIST) { - return this.executeDvcProcess(cwd, Command.REMOTE, arg) + public remote(cwd: string, ...args: Args) { + return this.executeDvcProcess(cwd, Command.REMOTE, ...args) } private executeExperimentProcess(cwd: string, ...args: Args) { diff --git a/extension/src/commands/external.ts b/extension/src/commands/external.ts index 151f106098..8e9ea99f03 100644 --- a/extension/src/commands/external.ts +++ b/extension/src/commands/external.ts @@ -41,6 +41,8 @@ export enum RegisteredCliCommands { REMOVE_TARGET = 'dvc.removeTarget', RENAME_TARGET = 'dvc.renameTarget', + REMOTE_ADD = 'dvc.addRemote', + GIT_STAGE_ALL = 'dvc.gitStageAll', GIT_UNSTAGE_ALL = 'dvc.gitUnstageAll' } diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 66bc19f6c8..4a55541d39 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -47,7 +47,7 @@ import { Flag } from './cli/dvc/constants' import { LanguageClient } from './languageClient' import { collectRunningExperimentPids } from './experiments/processExecution/collect' import { DvcViewer } from './cli/dvc/viewer' -import { registerSetupCommands } from './setup/register' +import { registerSetupCommands } from './setup/commands/register' import { Status } from './status' import { registerPersistenceCommands } from './persistence/register' diff --git a/extension/src/setup/commands/index.ts b/extension/src/setup/commands/index.ts new file mode 100644 index 0000000000..b4245c89d3 --- /dev/null +++ b/extension/src/setup/commands/index.ts @@ -0,0 +1,86 @@ +import { Setup } from '..' +import { Flag, SubCommand } from '../../cli/dvc/constants' +import { AvailableCommands, InternalCommands } from '../../commands/internal' +import { definedAndNonEmpty } from '../../util/array' +import { getInput } from '../../vscode/inputBox' +import { quickPickYesOrNo } from '../../vscode/quickPick' +import { Title } from '../../vscode/title' +import { Toast } from '../../vscode/toast' +import { getOnlyOrPickProject } from '../../workspace/util' + +const noExistingOrUserConfirms = async ( + internalCommands: InternalCommands, + dvcRoot: string +): Promise => { + const remoteList = await internalCommands.executeCommand( + AvailableCommands.REMOTE, + dvcRoot, + SubCommand.LIST + ) + + if (!remoteList) { + return true + } + + return await quickPickYesOrNo( + 'make this new remote the default', + 'keep the current default', + { + placeHolder: 'Would you like to set this new remote as the default?', + title: Title.SET_REMOTE_AS_DEFAULT + } + ) +} + +const addRemoteToProject = async ( + internalCommands: InternalCommands, + dvcRoot: string +): Promise => { + const name = await getInput(Title.ENTER_REMOTE_NAME) + if (!name) { + return + } + + const url = await getInput(Title.ENTER_REMOTE_URL) + if (!url) { + return + } + + const args = [Flag.PROJECT, name, url] + + const shouldSetAsDefault = await noExistingOrUserConfirms( + internalCommands, + dvcRoot + ) + if (shouldSetAsDefault === undefined) { + return + } + + if (shouldSetAsDefault) { + args.unshift(Flag.DEFAULT) + } + + return await Toast.showOutput( + internalCommands.executeCommand( + AvailableCommands.REMOTE, + dvcRoot, + SubCommand.ADD, + ...args + ) + ) +} + +export const getAddRemoteCommand = + (setup: Setup, internalCommands: InternalCommands) => + async (): Promise => { + const dvcRoots = setup.getRoots() + if (!definedAndNonEmpty(dvcRoots)) { + return Toast.showError('Cannot add a remote without a DVC project') + } + const dvcRoot = await getOnlyOrPickProject(dvcRoots) + + if (!dvcRoot) { + return + } + return addRemoteToProject(internalCommands, dvcRoot) + } diff --git a/extension/src/setup/register.ts b/extension/src/setup/commands/register.ts similarity index 82% rename from extension/src/setup/register.ts rename to extension/src/setup/commands/register.ts index f0e8468abc..9efd2c852e 100644 --- a/extension/src/setup/register.ts +++ b/extension/src/setup/commands/register.ts @@ -1,10 +1,14 @@ import { commands } from 'vscode' -import { Setup } from '.' -import { run } from './runner' -import { SetupSection } from './webview/contract' -import { AvailableCommands, InternalCommands } from '../commands/internal' -import { RegisteredCliCommands, RegisteredCommands } from '../commands/external' -import { getFirstWorkspaceFolder } from '../vscode/workspaceFolders' +import { getAddRemoteCommand } from '.' +import { Setup } from '..' +import { run } from '../runner' +import { SetupSection } from '../webview/contract' +import { AvailableCommands, InternalCommands } from '../../commands/internal' +import { + RegisteredCliCommands, + RegisteredCommands +} from '../../commands/external' +import { getFirstWorkspaceFolder } from '../../vscode/workspaceFolders' const registerSetupConfigCommands = ( setup: Setup, @@ -100,6 +104,11 @@ export const registerSetupCommands = ( } ) + internalCommands.registerExternalCliCommand( + RegisteredCliCommands.REMOTE_ADD, + getAddRemoteCommand(setup, internalCommands) + ) + registerSetupConfigCommands(setup, internalCommands) registerSetupShowCommands(setup, internalCommands) registerSetupStudioCommands(setup, internalCommands) diff --git a/extension/src/setup/webview/messages.ts b/extension/src/setup/webview/messages.ts index 2c6f767478..0a91f48b92 100644 --- a/extension/src/setup/webview/messages.ts +++ b/extension/src/setup/webview/messages.ts @@ -104,6 +104,8 @@ export class WebviewMessages { ) case MessageFromWebviewType.OPEN_EXPERIMENTS_WEBVIEW: return commands.executeCommand(RegisteredCommands.EXPERIMENT_SHOW) + case MessageFromWebviewType.REMOTE_ADD: + return commands.executeCommand(RegisteredCliCommands.REMOTE_ADD) default: Logger.error(`Unexpected message: ${JSON.stringify(message)}`) diff --git a/extension/src/telemetry/constants.ts b/extension/src/telemetry/constants.ts index da8ef8a87b..7fc37368c9 100644 --- a/extension/src/telemetry/constants.ts +++ b/extension/src/telemetry/constants.ts @@ -187,6 +187,8 @@ export interface IEventNamePropertyMapping { [EventName.REMOVE_TARGET]: undefined [EventName.RENAME_TARGET]: undefined + [EventName.REMOTE_ADD]: undefined + [EventName.GIT_STAGE_ALL]: undefined [EventName.GIT_UNSTAGE_ALL]: undefined diff --git a/extension/src/test/suite/setup/index.test.ts b/extension/src/test/suite/setup/index.test.ts index 9ef5143756..e823b30192 100644 --- a/extension/src/test/suite/setup/index.test.ts +++ b/extension/src/test/suite/setup/index.test.ts @@ -69,6 +69,7 @@ suite('Setup Test Suite', () => { ]) }) + // eslint-disable-next-line sonarjs/cognitive-complexity describe('Setup', () => { it('should handle an initialize git message from the webview', async () => { const { messageSpy, setup, mockInitializeGit } = buildSetup(disposable) @@ -843,6 +844,103 @@ suite('Setup Test Suite', () => { expect(mockShowWebview).to.be.calledOnce }).timeout(WEBVIEW_TEST_TIMEOUT) + it('should handle a message to add a remote', async () => { + const { messageSpy, setup, mockExecuteCommand } = buildSetup(disposable) + + const webview = await setup.showWebview() + await webview.isReady() + mockExecuteCommand.restore() + + const mockMessageReceived = getMessageReceivedEmitter(webview) + + const mockRemote = stub(DvcExecutor.prototype, 'remote') + + const remoteAdded = new Promise(resolve => + mockRemote.callsFake((cwd, ...args) => { + if (args.includes('add')) { + resolve(undefined) + } + return Promise.resolve('') + }) + ) + + const mockShowInputBox = stub(window, 'showInputBox') + .onFirstCall() + .resolves('storage') + .onSecondCall() + .resolves('s3://my-bucket') + + messageSpy.resetHistory() + mockMessageReceived.fire({ + type: MessageFromWebviewType.REMOTE_ADD + }) + + await remoteAdded + + expect(mockShowInputBox).to.be.calledTwice + expect( + mockRemote, + 'new remote is set as the default' + ).to.be.calledWithExactly( + dvcDemoPath, + 'add', + '-d', + '--project', + 'storage', + 's3://my-bucket' + ) + }).timeout(WEBVIEW_TEST_TIMEOUT) + + it('should be able to add a remote', async () => { + const mockRemote = stub(DvcExecutor.prototype, 'remote') + + const remoteAdded = new Promise(resolve => + mockRemote.callsFake((cwd, ...args) => { + if (args.includes('list')) { + return Promise.resolve('storage s3://my-bucket') + } + + if (args.includes('add')) { + resolve(undefined) + } + return Promise.resolve('') + }) + ) + + const mockShowInputBox = stub(window, 'showInputBox') + .onFirstCall() + .resolves('backup') + .onSecondCall() + .resolves('s3://my-backup-bucket') + + const mockShowQuickPick = ( + stub(window, 'showQuickPick') as SinonStub< + [items: readonly QuickPickItem[], options: QuickPickOptionsWithTitle], + Thenable | undefined> + > + ).resolves({ + label: 'No', + value: false + }) + + await commands.executeCommand(RegisteredCliCommands.REMOTE_ADD) + + await remoteAdded + + expect(mockShowInputBox).to.be.calledTwice + expect(mockShowQuickPick).to.be.calledOnce + expect( + mockRemote, + 'should not set a remote as the default unless the user explicitly chooses to' + ).to.be.calledWithExactly( + dvcDemoPath, + 'add', + '--project', + 'backup', + 's3://my-backup-bucket' + ) + }).timeout(WEBVIEW_TEST_TIMEOUT) + it('should send the appropriate messages to the webview to focus different sections', async () => { const { setup, messageSpy } = buildSetup(disposable) messageSpy.restore() diff --git a/extension/src/test/suite/setup/util.ts b/extension/src/test/suite/setup/util.ts index fd0d8a2405..f113dfde7c 100644 --- a/extension/src/test/suite/setup/util.ts +++ b/extension/src/test/suite/setup/util.ts @@ -44,7 +44,7 @@ export const buildSetup = ( const mockEmitter = disposer.track(new EventEmitter()) stub(dvcReader, 'root').resolves(mockDvcRoot) - stub(dvcExecutor, 'remote').resolves('') + const mockRemote = stub(dvcExecutor, 'remote').resolves('') const mockVersion = stub(dvcReader, 'version').resolves(MIN_CLI_VERSION) const mockGlobalVersion = stub(dvcReader, 'globalVersion').resolves( MIN_CLI_VERSION @@ -112,6 +112,7 @@ export const buildSetup = ( mockGlobalVersion, mockInitializeGit, mockOpenExternal, + mockRemote, mockRunSetup, mockShowWebview, mockVersion, diff --git a/extension/src/vscode/title.ts b/extension/src/vscode/title.ts index f5eb35800a..694ceea7ab 100644 --- a/extension/src/vscode/title.ts +++ b/extension/src/vscode/title.ts @@ -6,6 +6,8 @@ export enum Title { ENTER_EXPERIMENT_WORKER_COUNT = 'Enter the Number of Queue Workers', ENTER_FILTER_VALUE = 'Enter a Filter Value', ENTER_RELATIVE_DESTINATION = 'Enter a Destination Relative to the Root', + ENTER_REMOTE_NAME = 'Enter a Name for the Remote', + ENTER_REMOTE_URL = 'Enter the URL for the Remote', ENTER_PATH_OR_CHOOSE_FILE = 'Enter the path to your training script or select it', ENTER_STUDIO_USERNAME = 'Enter your Studio username', ENTER_STUDIO_TOKEN = 'Enter your Studio access token', @@ -36,7 +38,8 @@ export enum Title { SELECT_SORTS_TO_REMOVE = 'Select Sort(s) to Remove', SELECT_TRAINING_SCRIPT = 'Select your training script', SETUP_WORKSPACE = 'Setup the Workspace', - SET_EXPERIMENTS_HEADER_HEIGHT = 'Set Maximum Experiment Table Header Height' + SET_EXPERIMENTS_HEADER_HEIGHT = 'Set Maximum Experiment Table Header Height', + SET_REMOTE_AS_DEFAULT = 'Set Default Remote' } export const getEnterValueTitle = (path: string): Title => diff --git a/extension/src/webview/contract.ts b/extension/src/webview/contract.ts index fbf109194b..1f98ea74cf 100644 --- a/extension/src/webview/contract.ts +++ b/extension/src/webview/contract.ts @@ -51,6 +51,7 @@ export enum MessageFromWebviewType { SET_EXPERIMENTS_AND_OPEN_PLOTS = 'set-experiments-and-open-plots', SET_STUDIO_SHARE_EXPERIMENTS_LIVE = 'set-studio-share-experiments-live', TOGGLE_PLOTS_SECTION = 'toggle-plots-section', + REMOTE_ADD = 'remote-add', REMOVE_CUSTOM_PLOTS = 'remove-custom-plots', REMOVE_STUDIO_TOKEN = 'remove-studio-token', MODIFY_EXPERIMENT_PARAMS_AND_QUEUE = 'modify-experiment-params-and-queue', @@ -160,6 +161,7 @@ export type MessageFromWebview = type: MessageFromWebviewType.REMOVE_COLUMN_SORT payload: string } + | { type: MessageFromWebviewType.REMOTE_ADD } | { type: MessageFromWebviewType.REMOVE_CUSTOM_PLOTS } diff --git a/extension/src/workspace/index.ts b/extension/src/workspace/index.ts index f04f607355..207cc6e827 100644 --- a/extension/src/workspace/index.ts +++ b/extension/src/workspace/index.ts @@ -1,6 +1,6 @@ +import { getOnlyOrPickProject } from './util' import { InternalCommands } from '../commands/internal' import { Disposables, reset } from '../util/disposable' -import { quickPickOne } from '../vscode/quickPick' import { DeferredDisposable } from '../class/deferred' export abstract class BaseWorkspace< @@ -39,17 +39,9 @@ export abstract class BaseWorkspace< this.resetDeferred() } - public async getOnlyOrPickProject() { + public getOnlyOrPickProject() { const dvcRoots = this.getDvcRoots() - - if (dvcRoots.length === 1) { - return dvcRoots[0] - } - - return await quickPickOne( - dvcRoots, - 'Select which project to run command against' - ) + return getOnlyOrPickProject(dvcRoots) } public getRepository(dvcRoot: string) { diff --git a/extension/src/workspace/util.ts b/extension/src/workspace/util.ts new file mode 100644 index 0000000000..1ca5036298 --- /dev/null +++ b/extension/src/workspace/util.ts @@ -0,0 +1,14 @@ +import { quickPickOne } from '../vscode/quickPick' + +export const getOnlyOrPickProject = async ( + dvcRoots: string[] +): Promise => { + if (dvcRoots.length === 1) { + return dvcRoots[0] + } + + return await quickPickOne( + dvcRoots, + 'Select which project to run command against' + ) +} diff --git a/scripts/virtualenv-install.ts b/scripts/virtualenv-install.ts index e756cd683f..f2b64af35a 100644 --- a/scripts/virtualenv-install.ts +++ b/scripts/virtualenv-install.ts @@ -1,3 +1,4 @@ +/* eslint-disable */ import { join, resolve } from 'path' require('dvc/src/vscode/mockModule') diff --git a/webview/src/setup/components/App.test.tsx b/webview/src/setup/components/App.test.tsx index 672b161cc4..03beb10a26 100644 --- a/webview/src/setup/components/App.test.tsx +++ b/webview/src/setup/components/App.test.tsx @@ -792,10 +792,32 @@ describe('App', () => { } }) - const setupDVCButton = screen.getByText('Connect to Remote Storage') + const title = screen.getByText('Connect to Remote Storage') - expect(setupDVCButton).toBeInTheDocument() - expect(setupDVCButton).toBeVisible() + expect(title).toBeInTheDocument() + expect(title).toBeVisible() + }) + + it('should allow the user to connect a remote if they do not already have one', () => { + renderApp({ + remoteList: { demo: undefined, 'example-get-started': undefined }, + sectionCollapsed: { + [SetupSection.DVC]: true, + [SetupSection.EXPERIMENTS]: true, + [SetupSection.REMOTES]: false, + [SetupSection.STUDIO]: true + } + }) + mockPostMessage.mockReset() + const startButton = screen.getByText('Add Remote') + + expect(startButton).toBeInTheDocument() + expect(startButton).toBeVisible() + fireEvent.click(startButton) + expect(mockPostMessage).toHaveBeenCalledTimes(1) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: MessageFromWebviewType.REMOTE_ADD + }) }) it('should show the list of remotes if there is only one project in the workspace', () => { diff --git a/webview/src/setup/components/messages.ts b/webview/src/setup/components/messages.ts index 6f58caf0cc..698ac972f1 100644 --- a/webview/src/setup/components/messages.ts +++ b/webview/src/setup/components/messages.ts @@ -52,3 +52,6 @@ export const saveStudioToken = () => export const removeStudioToken = () => sendMessage({ type: MessageFromWebviewType.REMOVE_STUDIO_TOKEN }) + +export const addRemote = () => + sendMessage({ type: MessageFromWebviewType.REMOTE_ADD }) diff --git a/webview/src/setup/components/remote/Connect.tsx b/webview/src/setup/components/remote/Connect.tsx index eea4acf0a1..9f5b68ca5c 100644 --- a/webview/src/setup/components/remote/Connect.tsx +++ b/webview/src/setup/components/remote/Connect.tsx @@ -1,5 +1,7 @@ import React from 'react' import { EmptyState } from '../../../shared/components/emptyState/EmptyState' +import { StartButton } from '../../../shared/components/button/StartButton' +import { addRemote } from '../messages' export const Connect: React.FC = () => ( @@ -18,5 +20,6 @@ export const Connect: React.FC = () => ( {' '} for details on how to connect to a remote

+ addRemote()} text="Add Remote" />
) diff --git a/webview/src/stories/Setup.stories.tsx b/webview/src/stories/Setup.stories.tsx index 0e15735914..5be17782fc 100644 --- a/webview/src/stories/Setup.stories.tsx +++ b/webview/src/stories/Setup.stories.tsx @@ -154,6 +154,19 @@ CliAboveLatestTested.args = getUpdatedArgs({ isAboveLatestTestedVersion: true }) +export const NoRemoteSetup = Template.bind({}) +NoRemoteSetup.args = getUpdatedArgs({ + remoteList: { + '/Users/thatguy/projects/vscode-dvc/rootB': undefined + }, + sectionCollapsed: { + [SetupSection.DVC]: true, + [SetupSection.EXPERIMENTS]: true, + [SetupSection.REMOTES]: false, + [SetupSection.STUDIO]: true + } +}) + export const ProjectRemoteSetup = Template.bind({}) ProjectRemoteSetup.args = getUpdatedArgs({ remoteList: {