diff --git a/extension/src/cli/dvc/constants.ts b/extension/src/cli/dvc/constants.ts index 17e5a7a63b..2759f0ff5a 100644 --- a/extension/src/cli/dvc/constants.ts +++ b/extension/src/cli/dvc/constants.ts @@ -30,6 +30,7 @@ export enum Command { PUSH = 'push', QUEUE = 'queue', REMOVE = 'remove', + REMOTE = 'remote', ROOT = 'root', PARAMS = 'params', METRICS = 'metrics', diff --git a/extension/src/cli/dvc/executor.ts b/extension/src/cli/dvc/executor.ts index 5bd5d88719..8136837ab5 100644 --- a/extension/src/cli/dvc/executor.ts +++ b/extension/src/cli/dvc/executor.ts @@ -7,7 +7,8 @@ import { ExperimentSubCommand, Flag, GcPreserveFlag, - QueueSubCommand + QueueSubCommand, + SubCommand } from './constants' import { addStudioAccessToken } from './options' import { CliResult, CliStarted, typeCheckCommands } from '..' @@ -35,6 +36,7 @@ export const autoRegisteredCommands = { QUEUE_KILL: 'queueKill', QUEUE_START: 'queueStart', QUEUE_STOP: 'queueStop', + REMOTE: 'remote', REMOVE: 'remove' } as const @@ -196,6 +198,10 @@ 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) + } + private executeExperimentProcess(cwd: string, ...args: Args) { return this.executeDvcProcess(cwd, Command.EXPERIMENT, ...args) } diff --git a/extension/src/setup/collect.test.ts b/extension/src/setup/collect.test.ts new file mode 100644 index 0000000000..8f426d2a4f --- /dev/null +++ b/extension/src/setup/collect.test.ts @@ -0,0 +1,30 @@ +import { collectRemoteList } from './collect' +import { dvcDemoPath } from '../test/util' +import { join } from '../test/util/path' + +describe('collectRemoteList', () => { + it('should return the expected data structure', async () => { + const mockedRoot = join('some', 'other', 'root') + const remoteList = await collectRemoteList( + [dvcDemoPath, 'mockedOtherRoot', mockedRoot], + root => + Promise.resolve( + { + [dvcDemoPath]: + 'storage s3://dvc-public/remote/mnist-vscode\nbackup gdrive://appDataDir\nurl https://remote.dvc.org/mnist-vscode', + mockedOtherRoot: '', + [mockedRoot]: undefined + }[root] + ) + ) + expect(remoteList).toStrictEqual({ + [dvcDemoPath]: { + backup: 'gdrive://appDataDir', + storage: 's3://dvc-public/remote/mnist-vscode', + url: 'https://remote.dvc.org/mnist-vscode' + }, + mockedOtherRoot: undefined, + [mockedRoot]: undefined + }) + }) +}) diff --git a/extension/src/setup/collect.ts b/extension/src/setup/collect.ts index 75ad20c157..cadf226384 100644 --- a/extension/src/setup/collect.ts +++ b/extension/src/setup/collect.ts @@ -1,4 +1,9 @@ -import { DEFAULT_SECTION_COLLAPSED, SetupSection } from './webview/contract' +import { + DEFAULT_SECTION_COLLAPSED, + RemoteList, + SetupSection +} from './webview/contract' +import { trimAndSplit } from '../util/stdout' export const collectSectionCollapsed = ( focusedSection?: SetupSection @@ -16,3 +21,27 @@ export const collectSectionCollapsed = ( return acc } + +export const collectRemoteList = async ( + dvcRoots: string[], + getRemoteList: (cwd: string) => Promise +): Promise> => { + const acc: NonNullable = {} + + for (const dvcRoot of dvcRoots) { + const remoteList = await getRemoteList(dvcRoot) + if (!remoteList) { + acc[dvcRoot] = undefined + continue + } + const remotes = trimAndSplit(remoteList) + const dvcRootRemotes: { [alias: string]: string } = {} + for (const remote of remotes) { + const [alias, url] = remote.split(/\s+/) + dvcRootRemotes[alias] = url + } + acc[dvcRoot] = dvcRootRemotes + } + + return acc +} diff --git a/extension/src/setup/index.ts b/extension/src/setup/index.ts index 48a7621910..d52ef68ba5 100644 --- a/extension/src/setup/index.ts +++ b/extension/src/setup/index.ts @@ -7,7 +7,7 @@ import { SetupSection, SetupData as TSetupData } from './webview/contract' -import { collectSectionCollapsed } from './collect' +import { collectRemoteList, collectSectionCollapsed } from './collect' import { WebviewMessages } from './webview/messages' import { validateTokenInput } from './inputBox' import { findPythonBinForInstall } from './autoInstall' @@ -47,7 +47,8 @@ import { Flag, ConfigKey as DvcConfigKey, DOT_DVC, - Args + Args, + SubCommand } from '../cli/dvc/constants' import { GLOBAL_WEBVIEW_DVCROOT } from '../webview/factory' import { @@ -229,7 +230,7 @@ export class Setup ) { this.cliCompatible = compatible this.cliVersion = version - void this.updateIsStudioConnected() + void this.updateStudioAndSend() const incompatible = compatible === undefined ? undefined : !compatible void setContextValue(ContextKey.CLI_INCOMPATIBLE, incompatible) } @@ -339,7 +340,7 @@ export class Setup } await this.accessConfig(cwd, Flag.GLOBAL, DvcConfigKey.STUDIO_TOKEN, token) - return this.updateIsStudioConnected() + return this.updateStudioAndSend() } public getStudioLiveShareToken() { @@ -375,14 +376,28 @@ export class Setup } } + private async getRemoteList() { + await this.config.isReady() + + if (!this.hasRoots()) { + return undefined + } + + return collectRemoteList(this.dvcRoots, (cwd: string) => + this.accessRemote(cwd, SubCommand.LIST) + ) + } + private async sendDataToWebview() { const projectInitialized = this.hasRoots() const hasData = this.getHasData() - const [isPythonExtensionUsed, dvcCliDetails] = await Promise.all([ - this.isPythonExtensionUsed(), - this.getDvcCliDetails() - ]) + const [isPythonExtensionUsed, dvcCliDetails, remoteList] = + await Promise.all([ + this.isPythonExtensionUsed(), + this.getDvcCliDetails(), + this.getRemoteList() + ]) const needsGitInitialized = !projectInitialized && !!(await this.needsGitInit()) @@ -405,6 +420,7 @@ export class Setup needsGitInitialized, projectInitialized, pythonBinPath: getBinDisplayText(pythonBinPath), + remoteList, sectionCollapsed: collectSectionCollapsed(this.focusedSection), shareLiveToStudio: getConfigValue( ExtensionConfigKey.STUDIO_SHARE_EXPERIMENTS_LIVE @@ -644,16 +660,16 @@ export class Setup } } + private async updateStudioAndSend() { + await this.updateIsStudioConnected() + return this.sendDataToWebview() + } + private async updateIsStudioConnected() { await this.setStudioAccessToken() const storedToken = this.getStudioAccessToken() const isConnected = isStudioAccessToken(storedToken) - return this.setStudioIsConnected(isConnected) - } - - private setStudioIsConnected(isConnected: boolean) { this.studioIsConnected = isConnected - void this.sendDataToWebview() return setContextValue(ContextKey.STUDIO_CONNECTED, isConnected) } @@ -667,7 +683,7 @@ export class Setup path.endsWith(join('dvc', 'config')) || path.endsWith(join('dvc', 'config.local')) ) { - void this.updateIsStudioConnected() + void this.updateStudioAndSend() } } ) @@ -705,13 +721,23 @@ export class Setup ) } - private async accessConfig(cwd: string, ...args: Args) { + private accessConfig(cwd: string, ...args: Args) { + return this.accessDvc(AvailableCommands.CONFIG, cwd, ...args) + } + + private accessRemote(cwd: string, ...args: Args) { + return this.accessDvc(AvailableCommands.REMOTE, cwd, ...args) + } + + private async accessDvc( + commandId: + | typeof AvailableCommands.CONFIG + | typeof AvailableCommands.REMOTE, + cwd: string, + ...args: Args + ) { try { - return await this.internalCommands.executeCommand( - AvailableCommands.CONFIG, - cwd, - ...args - ) + return await this.internalCommands.executeCommand(commandId, cwd, ...args) } catch {} } } diff --git a/extension/src/setup/webview/contract.ts b/extension/src/setup/webview/contract.ts index f00442a13a..5d377c42b1 100644 --- a/extension/src/setup/webview/contract.ts +++ b/extension/src/setup/webview/contract.ts @@ -3,6 +3,10 @@ export type DvcCliDetails = { version: string | undefined } +export type RemoteList = + | { [dvcRoot: string]: { [alias: string]: string } | undefined } + | undefined + export type SetupData = { canGitInitialize: boolean cliCompatible: boolean | undefined @@ -14,20 +18,23 @@ export type SetupData = { needsGitInitialized: boolean | undefined projectInitialized: boolean pythonBinPath: string | undefined + remoteList: RemoteList sectionCollapsed: typeof DEFAULT_SECTION_COLLAPSED | undefined shareLiveToStudio: boolean } export enum SetupSection { + DVC = 'dvc', EXPERIMENTS = 'experiments', - STUDIO = 'studio', - DVC = 'dvc' + REMOTES = 'remotes', + STUDIO = 'studio' } export const DEFAULT_SECTION_COLLAPSED = { + [SetupSection.DVC]: false, [SetupSection.EXPERIMENTS]: false, - [SetupSection.STUDIO]: false, - [SetupSection.DVC]: false + [SetupSection.REMOTES]: false, + [SetupSection.STUDIO]: false } export type SectionCollapsed = typeof DEFAULT_SECTION_COLLAPSED diff --git a/extension/src/setup/webview/messages.ts b/extension/src/setup/webview/messages.ts index f352057fbc..b25eb00084 100644 --- a/extension/src/setup/webview/messages.ts +++ b/extension/src/setup/webview/messages.ts @@ -40,6 +40,7 @@ export class WebviewMessages { needsGitInitialized, projectInitialized, pythonBinPath, + remoteList, sectionCollapsed, shareLiveToStudio }: SetupData) { @@ -54,6 +55,7 @@ export class WebviewMessages { needsGitInitialized, projectInitialized, pythonBinPath, + remoteList, sectionCollapsed, shareLiveToStudio }) diff --git a/extension/src/test/suite/setup/index.test.ts b/extension/src/test/suite/setup/index.test.ts index ee0fc2df89..85baa08ce9 100644 --- a/extension/src/test/suite/setup/index.test.ts +++ b/extension/src/test/suite/setup/index.test.ts @@ -246,6 +246,7 @@ suite('Setup Test Suite', () => { needsGitInitialized: true, projectInitialized: false, pythonBinPath: undefined, + remoteList: undefined, sectionCollapsed: undefined, shareLiveToStudio: false }) @@ -287,6 +288,7 @@ suite('Setup Test Suite', () => { needsGitInitialized: true, projectInitialized: false, pythonBinPath: undefined, + remoteList: undefined, sectionCollapsed: undefined, shareLiveToStudio: false }) @@ -337,6 +339,7 @@ suite('Setup Test Suite', () => { needsGitInitialized: false, projectInitialized: false, pythonBinPath: undefined, + remoteList: undefined, sectionCollapsed: undefined, shareLiveToStudio: false }) @@ -387,6 +390,7 @@ suite('Setup Test Suite', () => { needsGitInitialized: false, projectInitialized: true, pythonBinPath: undefined, + remoteList: { [dvcDemoPath]: undefined }, sectionCollapsed: undefined, shareLiveToStudio: false }) diff --git a/extension/src/test/suite/setup/util.ts b/extension/src/test/suite/setup/util.ts index f976de40ff..f2cd3ec55e 100644 --- a/extension/src/test/suite/setup/util.ts +++ b/extension/src/test/suite/setup/util.ts @@ -44,6 +44,7 @@ export const buildSetup = ( const mockEmitter = disposer.track(new EventEmitter()) stub(dvcReader, 'root').resolves(mockDvcRoot) + stub(dvcExecutor, 'remote').resolves('') const mockVersion = stub(dvcReader, 'version').resolves(MIN_CLI_VERSION) const mockGlobalVersion = stub(dvcReader, 'globalVersion').resolves( MIN_CLI_VERSION diff --git a/webview/src/setup/components/App.test.tsx b/webview/src/setup/components/App.test.tsx index 13a7d57cdf..f4d9c19dfb 100644 --- a/webview/src/setup/components/App.test.tsx +++ b/webview/src/setup/components/App.test.tsx @@ -37,6 +37,7 @@ const DEFAULT_DATA = { needsGitInitialized: false, projectInitialized: true, pythonBinPath: undefined, + remoteList: undefined, sectionCollapsed: undefined, shareLiveToStudio: false } @@ -115,7 +116,7 @@ describe('App', () => { }) expect(screen.getAllByText('DVC is currently unavailable')).toHaveLength( - 2 + 3 ) }) @@ -221,7 +222,12 @@ describe('App', () => { it('should show a screen saying that DVC is not initialized if the project is not initialized and git is uninitialized', () => { renderApp({ needsGitInitialized: true, projectInitialized: false }) - expect(screen.getByText('DVC is not initialized')).toBeInTheDocument() + const uninitializedText = screen.getAllByText('DVC is not initialized') + + expect(uninitializedText).toHaveLength(2) + for (const text of uninitializedText) { + expect(text).toBeInTheDocument() + } }) it('should offer to initialize Git if it is possible', () => { @@ -244,19 +250,13 @@ describe('App', () => { projectInitialized: false }) - expect(screen.queryByText('Initialize Git')).not.toBeInTheDocument() - }) - - it('should show a screen saying that DVC is not initialized if the project is not initialized and dvc is installed', () => { - renderApp({ - projectInitialized: false - }) + const uninitialized = screen.getAllByText('DVC is not initialized') - expect(screen.getByText('DVC is not initialized')).toBeInTheDocument() + expect(uninitialized).toHaveLength(4) }) it('should not show a screen saying that DVC is not initialized if the project is initialized and dvc is installed', () => { - renderApp() + renderApp({ remoteList: { mocRoot: undefined } }) expect( screen.queryByText('DVC is not initialized') @@ -281,8 +281,9 @@ describe('App', () => { projectInitialized: false, sectionCollapsed: { [SetupSection.DVC]: false, - [SetupSection.STUDIO]: true, - [SetupSection.EXPERIMENTS]: true + [SetupSection.EXPERIMENTS]: true, + [SetupSection.REMOTES]: true, + [SetupSection.STUDIO]: true } } @@ -401,7 +402,8 @@ describe('App', () => { }) it('should show a passed icon if DVC CLI is compatible and project is initialized', () => { - renderApp() + renderApp({ remoteList: { mockRoot: undefined } }) + expect(screen.queryByText('DVC is not setup')).not.toBeInTheDocument() const iconWrapper = screen.getAllByTestId('info-tooltip-toggle')[0] @@ -409,20 +411,11 @@ describe('App', () => { within(iconWrapper).getByTestId(TooltipIconType.PASSED) ).toBeInTheDocument() }) - }) - - describe('Experiments', () => { - it('should show a screen saying that dvc is not setup if the project is not initialized', () => { - renderApp({ - projectInitialized: false - }) - - expect(screen.getByText('DVC is not setup')).toBeInTheDocument() - }) it('should open the dvc section when clicking the Setup DVC button on the dvc is not setup screen', () => { renderApp({ - projectInitialized: false + projectInitialized: false, + remoteList: { mockRoot: undefined } }) const experimentsText = screen.getByText('DVC is not setup') @@ -578,7 +571,7 @@ describe('App', () => { cliCompatible: false }) - const iconWrapper = screen.getAllByTestId('info-tooltip-toggle')[2] + const iconWrapper = screen.getAllByTestId('info-tooltip-toggle')[3] expect( within(iconWrapper).getByTestId(TooltipIconType.ERROR) @@ -588,7 +581,7 @@ describe('App', () => { it('should show an info icon if dvc is compatible but studio is not connected', () => { renderApp() - const iconWrapper = screen.getAllByTestId('info-tooltip-toggle')[2] + const iconWrapper = screen.getAllByTestId('info-tooltip-toggle')[3] expect( within(iconWrapper).getByTestId(TooltipIconType.INFO) @@ -629,7 +622,7 @@ describe('App', () => { isStudioConnected: true }) - const iconWrapper = screen.getAllByTestId('info-tooltip-toggle')[2] + const iconWrapper = screen.getAllByTestId('info-tooltip-toggle')[3] expect( within(iconWrapper).getByTestId(TooltipIconType.PASSED) @@ -646,9 +639,10 @@ describe('App', () => { renderApp({ isStudioConnected: true, sectionCollapsed: { + [SetupSection.DVC]: false, [SetupSection.EXPERIMENTS]: true, - [SetupSection.STUDIO]: true, - [SetupSection.DVC]: false + [SetupSection.REMOTES]: true, + [SetupSection.STUDIO]: true } }) mockPostMessage.mockClear() @@ -667,9 +661,10 @@ describe('App', () => { renderApp({ isStudioConnected: true, sectionCollapsed: { + [SetupSection.DVC]: true, [SetupSection.EXPERIMENTS]: false, - [SetupSection.STUDIO]: true, - [SetupSection.DVC]: true + [SetupSection.REMOTES]: true, + [SetupSection.STUDIO]: true } }) mockPostMessage.mockClear() @@ -688,9 +683,10 @@ describe('App', () => { renderApp({ isStudioConnected: true, sectionCollapsed: { + [SetupSection.DVC]: true, [SetupSection.EXPERIMENTS]: true, - [SetupSection.STUDIO]: false, - [SetupSection.DVC]: true + [SetupSection.REMOTES]: true, + [SetupSection.STUDIO]: false } }) mockPostMessage.mockClear() @@ -705,4 +701,95 @@ describe('App', () => { expect(screen.getByText(experimentsText)).not.toBeVisible() }) }) + + describe('Remotes', () => { + it('should show the setup DVC button if the remoteList is undefined (no projects)', () => { + renderApp({ + remoteList: undefined, + sectionCollapsed: { + [SetupSection.DVC]: true, + [SetupSection.EXPERIMENTS]: true, + [SetupSection.REMOTES]: false, + [SetupSection.STUDIO]: true + } + }) + + const setupDVCButton = screen.getByText('Setup DVC') + + expect(setupDVCButton).toBeInTheDocument() + expect(setupDVCButton).toBeVisible() + }) + + it('should show the connect to remote storage screen if no remotes are connected', () => { + renderApp({ + remoteList: { demo: undefined, 'example-get-started': undefined }, + sectionCollapsed: { + [SetupSection.DVC]: true, + [SetupSection.EXPERIMENTS]: true, + [SetupSection.REMOTES]: false, + [SetupSection.STUDIO]: true + } + }) + + const setupDVCButton = screen.getByText('Connect to Remote Storage') + + expect(setupDVCButton).toBeInTheDocument() + expect(setupDVCButton).toBeVisible() + }) + + it('should show the list of remotes if there is only one project in the workspace', () => { + renderApp({ + remoteList: { + 'example-get-started': { drive: 'gdrive://appDataFolder' } + }, + sectionCollapsed: { + [SetupSection.DVC]: true, + [SetupSection.EXPERIMENTS]: true, + [SetupSection.REMOTES]: false, + [SetupSection.STUDIO]: true + } + }) + + const setupDVCButton = screen.getByText('Remote Storage Connected') + + expect(setupDVCButton).toBeInTheDocument() + expect(setupDVCButton).toBeVisible() + + expect(screen.getByText('drive')).toBeInTheDocument() + expect(screen.getByText('gdrive://appDataFolder')).toBeInTheDocument() + expect(screen.queryByText('example-get-started')).not.toBeInTheDocument() + }) + + it('should show the list of remotes by project if there are multiple projects and one has remote(s) connected', () => { + renderApp({ + remoteList: { + demo: undefined, + 'example-get-started': { + drive: 'gdrive://appDataFolder', + storage: 's3://some-bucket' + } + }, + sectionCollapsed: { + [SetupSection.DVC]: true, + [SetupSection.EXPERIMENTS]: true, + [SetupSection.REMOTES]: false, + [SetupSection.STUDIO]: true + } + }) + + const setupDVCButton = screen.getByText('Remote Storage Connected') + + expect(setupDVCButton).toBeInTheDocument() + expect(setupDVCButton).toBeVisible() + + expect(screen.getByText('demo')).toBeInTheDocument() + expect(screen.getAllByText('-')).toHaveLength(2) + + expect(screen.getByText('example-get-started')).toBeInTheDocument() + expect(screen.getByText('drive')).toBeInTheDocument() + expect(screen.getByText('gdrive://appDataFolder')).toBeInTheDocument() + expect(screen.getByText('storage')).toBeInTheDocument() + expect(screen.getByText('s3://some-bucket')).toBeInTheDocument() + }) + }) }) diff --git a/webview/src/setup/components/App.tsx b/webview/src/setup/components/App.tsx index 1afb6e069e..68bd26d48e 100644 --- a/webview/src/setup/components/App.tsx +++ b/webview/src/setup/components/App.tsx @@ -9,6 +9,7 @@ import { Dvc } from './dvc/Dvc' import { Experiments } from './experiments/Experiments' import { Studio } from './studio/Studio' import { SetupContainer } from './SetupContainer' +import { Remotes } from './remote/Remotes' import { useVsCodeMessaging } from '../../shared/hooks/useVsCodeMessaging' import { sendMessage } from '../../shared/vscode' import { SetupDispatch, SetupState } from '../store' @@ -25,14 +26,15 @@ import { updateProjectInitialized, updatePythonBinPath } from '../state/dvcSlice' -import { - updateIsStudioConnected, - updateShareLiveToStudio -} from '../state/studioSlice' import { updateHasData as updateExperimentsHasData, updateNeedsGitCommit } from '../state/experimentsSlice' +import { updateRemoteList } from '../state/remoteSlice' +import { + updateIsStudioConnected, + updateShareLiveToStudio +} from '../state/studioSlice' export const feedStore = ( data: MessageToWebview, @@ -80,6 +82,10 @@ export const feedStore = ( case 'shareLiveToStudio': dispatch(updateShareLiveToStudio(data.data.shareLiveToStudio)) continue + case 'remoteList': + dispatch(updateRemoteList(data.data.remoteList)) + continue + default: continue } @@ -93,6 +99,8 @@ export const App: React.FC = () => { const hasExperimentsData = useSelector( (state: SetupState) => state.experiments.hasData ) + const { remoteList } = useSelector((state: SetupState) => state.remote) + const isStudioConnected = useSelector( (state: SetupState) => state.studio.isStudioConnected ) @@ -134,6 +142,13 @@ export const App: React.FC = () => { > + + + { const dispatch = useDispatch() @@ -29,13 +29,7 @@ export const Dvc: React.FC = () => { } if (previousIsComplete === false && isComplete) { - dispatch( - updateSectionCollapsed({ - [SetupSection.DVC]: true, - [SetupSection.EXPERIMENTS]: false, - [SetupSection.STUDIO]: false - }) - ) + dispatch(closeSection(SetupSection.DVC)) } }, [ dispatch, diff --git a/webview/src/setup/components/experiments/Experiments.tsx b/webview/src/setup/components/experiments/Experiments.tsx index 29b8c620a0..9dc8a149dc 100644 --- a/webview/src/setup/components/experiments/Experiments.tsx +++ b/webview/src/setup/components/experiments/Experiments.tsx @@ -1,22 +1,19 @@ import React from 'react' -import { useDispatch, useSelector } from 'react-redux' -import { SetupSection } from 'dvc/src/setup/webview/contract' +import { useSelector } from 'react-redux' import { NoData } from './NoData' import { NeedsGitCommit } from './NeedsGitCommit' import { showExperiments } from '../messages' import { EmptyState } from '../../../shared/components/emptyState/EmptyState' import { IconButton } from '../../../shared/components/button/IconButton' import { Beaker } from '../../../shared/components/icons' -import { Button } from '../../../shared/components/button/Button' -import { updateSectionCollapsed } from '../../state/webviewSlice' import { SetupState } from '../../store' +import { FocusDvcSection } from '../shared/FocusDvcSection' type ExperimentsProps = { isDvcSetup: boolean } export const Experiments: React.FC = ({ isDvcSetup }) => { - const dispatch = useDispatch() const { needsGitCommit, hasData } = useSelector( (state: SetupState) => state.experiments ) @@ -26,18 +23,7 @@ export const Experiments: React.FC = ({ isDvcSetup }) => {

DVC is not setup

DVC needs to be setup before you can access experiments.

-