diff --git a/CHANGELOG.md b/CHANGELOG.md index ea7ce685b0..56dc5815a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ All notable changes to this project will be documented in this file. +## [0.8.16] - 2023-05-18 + +### 🚀 New Features and Enhancements + +- Add setup warning about latest tested version [#3895](https://github.com/iterative/vscode-dvc/pull/3895) by [@julieg18](https://github.com/julieg18) +- Add upgrade dvc button to setup when incompatible [#3904](https://github.com/iterative/vscode-dvc/pull/3904) by [@julieg18](https://github.com/julieg18) + +### 🔨 Maintenance + +- Update demo project and latest tested CLI version (2.57.2) [#3931](https://github.com/iterative/vscode-dvc/pull/3931) by [@mattseddon](https://github.com/mattseddon) + +## [0.8.15] - 2023-05-18 + +### 🐛 Bug Fixes + +- Ensure unique list of experiment passed to the rest of the app [#3925](https://github.com/iterative/vscode-dvc/pull/3925) by [@mattseddon](https://github.com/mattseddon) + +## [0.8.14] - 2023-05-18 + +### 🚀 New Features and Enhancements + +- Add Remotes section to setup webview [#3901](https://github.com/iterative/vscode-dvc/pull/3901) by [@mattseddon](https://github.com/mattseddon) + +### 🔨 Maintenance + +- Make use of show experiments command for setup page [#3910](https://github.com/iterative/vscode-dvc/pull/3910) by [@mattseddon](https://github.com/mattseddon) +- Add default test data to setup app tests [#3908](https://github.com/iterative/vscode-dvc/pull/3908) by [@mattseddon](https://github.com/mattseddon) +- Fix typo in setup reducers [#3912](https://github.com/iterative/vscode-dvc/pull/3912) by [@mattseddon](https://github.com/mattseddon) +- Create default exp show output constant [#3911](https://github.com/iterative/vscode-dvc/pull/3911) by [@mattseddon](https://github.com/mattseddon) + ## [0.8.13] - 2023-05-17 ### 🚀 New Features and Enhancements diff --git a/demo b/demo index 5f06c3734d..a20953baeb 160000 --- a/demo +++ b/demo @@ -1 +1 @@ -Subproject commit 5f06c3734d76cb7ca894895e89d6d06dd878f8c4 +Subproject commit a20953baeb985bdfa41490d220da32942345864f diff --git a/extension/package.json b/extension/package.json index 6f6a33db52..5c30da8e11 100644 --- a/extension/package.json +++ b/extension/package.json @@ -9,7 +9,7 @@ "extensionDependencies": [ "vscode.git" ], - "version": "0.8.13", + "version": "0.8.16", "license": "Apache-2.0", "readme": "./README.md", "repository": { @@ -1677,10 +1677,10 @@ "@types/vscode": "1.64.0", "@vscode/test-electron": "2.3.2", "@vscode/vsce": "2.19.0", - "@wdio/cli": "8.10.1", - "@wdio/local-runner": "8.10.1", - "@wdio/mocha-framework": "8.10.1", - "@wdio/spec-reporter": "8.10.1", + "@wdio/cli": "8.10.2", + "@wdio/local-runner": "8.10.2", + "@wdio/mocha-framework": "8.10.2", + "@wdio/spec-reporter": "8.10.2", "chai": "4.3.7", "chai-as-promised": "7.1.1", "clean-webpack-plugin": "4.0.0", @@ -1697,7 +1697,7 @@ "ts-loader": "9.4.2", "vscode-uri": "3.0.7", "wdio-vscode-service": "5.1.0", - "webdriverio": "8.10.1", + "webdriverio": "8.10.2", "webpack": "5.82.1", "webpack-cli": "5.1.1" }, 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/contract.ts b/extension/src/cli/dvc/contract.ts index f6f4d89e5d..a590eac07b 100644 --- a/extension/src/cli/dvc/contract.ts +++ b/extension/src/cli/dvc/contract.ts @@ -1,7 +1,7 @@ import { Plot } from '../../plots/webview/contract' export const MIN_CLI_VERSION = '2.55.0' -export const LATEST_TESTED_CLI_VERSION = '2.57.1' +export const LATEST_TESTED_CLI_VERSION = '2.57.2' export const MAX_CLI_VERSION = '3' type ErrorContents = { type: string; msg: string } @@ -60,6 +60,13 @@ export type MetricsOrParams = RelPathObject export const fileHasError = (file: FileDataOrError): file is DvcError => !!(file as DvcError).error +export const DEFAULT_EXP_SHOW_OUTPUT = [ + { + branch: undefined, + rev: EXPERIMENT_WORKSPACE_ID + } +] + export type ExpData = { rev: string timestamp: string | null 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/cli/dvc/reader.ts b/extension/src/cli/dvc/reader.ts index 3a08a41c62..8c5fc15797 100644 --- a/extension/src/cli/dvc/reader.ts +++ b/extension/src/cli/dvc/reader.ts @@ -10,12 +10,12 @@ import { } from './constants' import { DataStatusOutput, + DEFAULT_EXP_SHOW_OUTPUT, DvcError, - EXPERIMENT_WORKSPACE_ID, + ExpShowOutput, PlotsOutput, PlotsOutputOrError, - RawPlotsOutput, - ExpShowOutput + RawPlotsOutput } from './contract' import { getOptions } from './options' import { typeCheckCommands } from '..' @@ -76,8 +76,7 @@ export class DvcReader extends DvcCli { if (isDvcError(output) || isEmpty(output)) { return [ { - branch: undefined, - rev: EXPERIMENT_WORKSPACE_ID, + ...DEFAULT_EXP_SHOW_OUTPUT[0], ...(output as DvcError) } ] diff --git a/extension/src/cli/dvc/version.test.ts b/extension/src/cli/dvc/version.test.ts index 8124ea4de5..deac25c59b 100644 --- a/extension/src/cli/dvc/version.test.ts +++ b/extension/src/cli/dvc/version.test.ts @@ -2,7 +2,8 @@ import { isVersionCompatible, extractSemver, ParsedSemver, - CliCompatible + CliCompatible, + isAboveLatestTestedVersion } from './version' import { MIN_CLI_VERSION, LATEST_TESTED_CLI_VERSION } from './contract' @@ -161,3 +162,47 @@ describe('isVersionCompatible', () => { expect(isCompatible).toStrictEqual(CliCompatible.NO_CANNOT_VERIFY) }) }) + +describe('isAboveLatestTestedVersion', () => { + it('should return undefined if version is undefined', () => { + const result = isAboveLatestTestedVersion(undefined) + + expect(result).toStrictEqual(undefined) + }) + + it('should return true for a version with a minor higher as the latest tested minor and any patch', () => { + const { + major: latestTestedMajor, + minor: latestTestedMinor, + patch: latestTestedPatch + } = extractSemver(LATEST_TESTED_CLI_VERSION) as ParsedSemver + + expect(0).toBeLessThan(latestTestedPatch) + + let result = isAboveLatestTestedVersion( + [latestTestedMajor, latestTestedMinor + 1, 0].join('.') + ) + + expect(result).toStrictEqual(true) + + result = isAboveLatestTestedVersion( + [latestTestedMajor, latestTestedMinor + 1, latestTestedPatch + 1000].join( + '.' + ) + ) + + expect(result).toStrictEqual(true) + + result = isAboveLatestTestedVersion( + [latestTestedMajor, latestTestedMinor + 1, latestTestedPatch].join('.') + ) + + expect(result).toStrictEqual(true) + }) + + it('should return false if version is below the latest tested version', () => { + const result = isAboveLatestTestedVersion(MIN_CLI_VERSION) + + expect(result).toStrictEqual(false) + }) +}) diff --git a/extension/src/cli/dvc/version.ts b/extension/src/cli/dvc/version.ts index 33fca71d78..b0c817f833 100644 --- a/extension/src/cli/dvc/version.ts +++ b/extension/src/cli/dvc/version.ts @@ -1,4 +1,8 @@ -import { MAX_CLI_VERSION, MIN_CLI_VERSION } from './contract' +import { + LATEST_TESTED_CLI_VERSION, + MAX_CLI_VERSION, + MIN_CLI_VERSION +} from './contract' export enum CliCompatible { NO_CANNOT_VERIFY = 'no-cannot-verify', @@ -67,3 +71,19 @@ export const isVersionCompatible = ( return checkCLIVersion(currentSemVer) } + +export const isAboveLatestTestedVersion = (version: string | undefined) => { + if (!version) { + return undefined + } + + const { major: currentMajor, minor: currentMinor } = extractSemver( + version + ) as ParsedSemver + + const { major: latestTestedMajor, minor: latestTestedMinor } = extractSemver( + LATEST_TESTED_CLI_VERSION + ) as ParsedSemver + + return currentMajor === latestTestedMajor && currentMinor > latestTestedMinor +} diff --git a/extension/src/experiments/index.ts b/extension/src/experiments/index.ts index bf4f1a6589..67c3d63f12 100644 --- a/extension/src/experiments/index.ts +++ b/extension/src/experiments/index.ts @@ -626,7 +626,7 @@ export class Experiments extends BaseRepository { } return await pickExperiment( - this.experiments.getCombinedList(), + this.experiments.getUniqueList(), this.getFirstThreeColumnOrder(), Title.SELECT_BASE_EXPERIMENT ) diff --git a/extension/src/experiments/model/collect.test.ts b/extension/src/experiments/model/collect.test.ts index ef5825d98d..3a31863dd0 100644 --- a/extension/src/experiments/model/collect.test.ts +++ b/extension/src/experiments/model/collect.test.ts @@ -85,4 +85,47 @@ describe('collectExperiments', () => { tags: [] }) }) + + it('should not collect the same experiment twice', () => { + const main = { + experiments: [ + { + name: 'campy-pall', + rev: '0b4b001dfaa8f2c4cd2a62238699131ab2c679ea' + }, + { + name: 'shyer-stir', + rev: '450e672f0d8913517ab2ab443f5d87b34f308290' + } + ], + name: 'main', + rev: '61bed4ce8913eca7f73ca754d65bc5daad1520e2' + } + + const expShowWithDuplicateCommits = generateTestExpShowOutput( + {}, + main, + { + name: 'branchOffMainWithCommit', + rev: '351e42ace3cb6a3a853c65bef285e60748cc6341' + }, + main + ) + + const { experimentsByCommit, commits } = collectExperiments( + expShowWithDuplicateCommits, + false, + '' + ) + + expect(commits.length).toStrictEqual(3) + + const experiments = experimentsByCommit.get('main') + + expect(experiments?.length).toStrictEqual(2) + expect(experiments?.map(({ id }) => id).sort()).toStrictEqual([ + 'campy-pall', + 'shyer-stir' + ]) + }) }) diff --git a/extension/src/experiments/model/collect.ts b/extension/src/experiments/model/collect.ts index 2cfdc14334..95fa030611 100644 --- a/extension/src/experiments/model/collect.ts +++ b/extension/src/experiments/model/collect.ts @@ -251,16 +251,25 @@ const collectExpRange = ( const expState = revs[0] const { name, rev } = expState - const { branch, id } = baseline + const { id: baselineId } = baseline const label = rev === EXPERIMENT_WORKSPACE_ID ? EXPERIMENT_WORKSPACE_ID : shortenForLabel(rev) + const experimentId = name || label + + if ( + acc.experimentsByCommit + .get(baselineId) + ?.find(({ id }) => id === experimentId) + ) { + return + } + const experiment = transformExpState( { - branch, id: name || label, label }, @@ -275,7 +284,7 @@ const collectExpRange = ( collectExecutorInfo(experiment, executor) collectRunningExperiment(acc, experiment) - addToMapArray(acc.experimentsByCommit, id, experiment) + addToMapArray(acc.experimentsByCommit, baselineId, experiment) } const setWorkspaceAsRunning = ( diff --git a/extension/src/experiments/model/index.test.ts b/extension/src/experiments/model/index.test.ts index d3e708c730..b305b2b893 100644 --- a/extension/src/experiments/model/index.test.ts +++ b/extension/src/experiments/model/index.test.ts @@ -141,7 +141,7 @@ describe('ExperimentsModel', () => { model.transformAndSet(data, false, '') - const experiments = model.getCombinedList() + const experiments = model.getUniqueList() const changed: string[] = [] for (const { deps, sha } of experiments) { @@ -273,7 +273,7 @@ describe('ExperimentsModel', () => { model.setSelected([]) expect(model.getSelectedRevisions().map(({ id }) => id)).toStrictEqual([]) - model.setSelected(model.getCombinedList()) + model.setSelected(model.getUniqueList()) expect(model.getSelectedRevisions().map(({ id }) => id)).toStrictEqual([ EXPERIMENT_WORKSPACE_ID, 'testBranch', @@ -358,4 +358,60 @@ describe('ExperimentsModel', () => { 'three' ]) }) + + it('should handle duplicate commits being returned in the data', () => { + const main = { + experiments: [ + { + name: 'campy-pall', + rev: '0b4b001dfaa8f2c4cd2a62238699131ab2c679ea' + }, + { + name: 'shyer-stir', + rev: '450e672f0d8913517ab2ab443f5d87b34f308290' + } + ], + name: 'main', + rev: '61bed4ce8913eca7f73ca754d65bc5daad1520e2' + } + + const expShowWithDuplicateCommits = generateTestExpShowOutput( + {}, + main, + { + name: 'branchOffMainWithCommit', + rev: '351e42ace3cb6a3a853c65bef285e60748cc6341' + }, + main + ) + + const model = new ExperimentsModel('', buildMockMemento()) + model.transformAndSet(expShowWithDuplicateCommits, false, '') + const distinctTreeItems = model.getWorkspaceAndCommits() + expect(distinctTreeItems).toHaveLength(3) + + const tableRows = model.getRowData() + expect(tableRows).toHaveLength(4) + expect(tableRows.map(({ id }) => id)).toStrictEqual([ + EXPERIMENT_WORKSPACE_ID, + 'main', + 'branchOffMainWithCommit', + 'main' + ]) + + const tableExperimentRows = ( + tableRows[1] as unknown as { subRows: Experiment[] } + ).subRows + + const duplicateTableExperimentRows = ( + tableRows[3] as unknown as { subRows: Experiment[] } + ).subRows + + expect(tableExperimentRows).toHaveLength(2) + expect(tableExperimentRows.map(({ id }) => id)).toStrictEqual([ + 'campy-pall', + 'shyer-stir' + ]) + expect(tableExperimentRows).toStrictEqual(duplicateTableExperimentRows) + }) }) diff --git a/extension/src/experiments/model/index.ts b/extension/src/experiments/model/index.ts index 85b3062609..77655aee38 100644 --- a/extension/src/experiments/model/index.ts +++ b/extension/src/experiments/model/index.ts @@ -248,11 +248,25 @@ export class ExperimentsModel extends ModelWithPersistence { } public getRevisionIds() { - return this.getCombinedList().map(({ id }) => id) + return this.getUniqueList().map(({ id }) => id) } public getSelectedRevisions() { - return this.getSelectedFromList(() => this.getCombinedList()) + const acc: SelectedExperimentWithColor[] = [] + + for (const experiment of this.getUniqueList()) { + const { id } = experiment + const displayColor = this.coloredStatus[id] + if (displayColor) { + acc.push({ ...experiment, displayColor } as SelectedExperimentWithColor) + } + } + + return copyOriginalColors() + .flatMap(orderedItem => + acc.filter(item => item.displayColor === orderedItem) + ) + .filter(Boolean) } public setSelected(selectedExperiments: Experiment[]) { @@ -273,7 +287,7 @@ export class ExperimentsModel extends ModelWithPersistence { } public getLabels() { - return this.getCombinedList().map(({ label }) => label) + return this.getUniqueList().map(({ label }) => label) } public getLabelsToDecorate() { @@ -283,7 +297,7 @@ export class ExperimentsModel extends ModelWithPersistence { } public getWorkspaceAndCommits() { - return [ + const experiments = [ { ...this.addDetails(this.workspace), hasChildren: false, @@ -292,15 +306,18 @@ export class ExperimentsModel extends ModelWithPersistence { ) ? ExperimentType.RUNNING : ExperimentType.WORKSPACE - }, - ...this.commits.map(commit => { - return { - ...this.addDetails(commit), - hasChildren: !!this.experimentsByCommit.get(commit.id), - type: ExperimentType.COMMIT - } - }) + } ] + + for (const commit of this.getCommits()) { + experiments.push({ + ...this.addDetails(commit), + hasChildren: !!this.experimentsByCommit.get(commit.id), + type: ExperimentType.COMMIT + }) + } + + return experiments } public getWorkspaceCommitsAndExperiments() { @@ -309,14 +326,14 @@ export class ExperimentsModel extends ModelWithPersistence { public getErrors() { return new Set( - this.getCombinedList() + this.getUniqueList() .filter(({ error }) => error) .map(({ label }) => label) ) } public getExperimentParams(id: string) { - const params = this.getCombinedList().find( + const params = this.getUniqueList().find( experiment => experiment.id === id )?.params @@ -330,7 +347,7 @@ export class ExperimentsModel extends ModelWithPersistence { } public getCommitsAndExperiments() { - return collectOrderedCommitsAndExperiments(this.commits, commit => + return collectOrderedCommitsAndExperiments(this.getCommits(), commit => this.getExperimentsByCommit(commit) ) } @@ -387,7 +404,11 @@ export class ExperimentsModel extends ModelWithPersistence { } public getExperimentCount() { - return sum([this.getExperimentsAndQueued().length, this.commits.length, 1]) + return sum([ + this.getExperimentsAndQueued().length, + this.getCommits().length, + 1 + ]) } public getFilteredCount() { @@ -395,8 +416,12 @@ export class ExperimentsModel extends ModelWithPersistence { return filtered.length } - public getCombinedList() { - return [this.workspace, ...this.commits, ...this.getExperimentsAndQueued()] + public getUniqueList() { + return [ + this.workspace, + ...this.getCommits(), + ...this.getExperimentsAndQueued() + ] } public getExperimentsByCommitForTree(commit: Experiment) { @@ -452,6 +477,20 @@ export class ExperimentsModel extends ModelWithPersistence { return this.currentSorts.findIndex(({ path }) => path === pathToRemove) } + private getCommits() { + const ids = new Set() + const commits: Experiment[] = [] + for (const commit of this.commits) { + const { id } = commit + if (ids.has(id)) { + continue + } + commits.push(commit) + ids.add(id) + } + return commits + } + private getFilteredExperiments() { const acc: Experiment[] = [] @@ -467,7 +506,10 @@ export class ExperimentsModel extends ModelWithPersistence { private getExperimentsByCommit(commit: Experiment) { const experiments = this.experimentsByCommit .get(commit.id) - ?.map(experiment => this.addDetails(experiment)) + ?.map(experiment => ({ + ...this.addDetails(experiment), + branch: commit.branch + })) if (!experiments) { return } @@ -607,23 +649,6 @@ export class ExperimentsModel extends ModelWithPersistence { return color } - private getSelectedFromList(getList: () => Experiment[]) { - const acc: SelectedExperimentWithColor[] = [] - - for (const experiment of getList()) { - const displayColor = this.coloredStatus[experiment.id] - if (displayColor) { - acc.push({ ...experiment, displayColor } as SelectedExperimentWithColor) - } - } - - return copyOriginalColors() - .flatMap(orderedItem => - acc.filter(item => item.displayColor === orderedItem) - ) - .filter(Boolean) - } - private reviveColoredStatus() { const uniqueStatus: ColoredStatus = {} const colors = new Set() diff --git a/extension/src/experiments/webview/contract.ts b/extension/src/experiments/webview/contract.ts index 903eade6b8..c688cf29f8 100644 --- a/extension/src/experiments/webview/contract.ts +++ b/extension/src/experiments/webview/contract.ts @@ -46,7 +46,7 @@ export type Experiment = { starred?: boolean status?: ExperimentStatus timestamp?: string | null - branch: string | undefined + branch?: string } export const isRunning = (status: ExperimentStatus | undefined): boolean => diff --git a/extension/src/experiments/webview/messages.ts b/extension/src/experiments/webview/messages.ts index 89be813202..c613a3821b 100644 --- a/extension/src/experiments/webview/messages.ts +++ b/extension/src/experiments/webview/messages.ts @@ -407,7 +407,7 @@ export class WebviewMessages { private setSelectedExperiments(ids: string[]) { const experiments = this.experiments - .getCombinedList() + .getUniqueList() .filter(({ id }) => ids.includes(id)) this.experiments.setSelected(experiments) diff --git a/extension/src/setup/autoInstall.ts b/extension/src/setup/autoInstall.ts index b38480e43a..ec6e08e328 100644 --- a/extension/src/setup/autoInstall.ts +++ b/extension/src/setup/autoInstall.ts @@ -16,6 +16,31 @@ export const findPythonBinForInstall = async (): Promise< ) } +const showUpgradeProgress = ( + root: string, + pythonBinPath: string +): Thenable => + Toast.showProgress('Upgrading DVC', async progress => { + progress.report({ increment: 0 }) + + progress.report({ increment: 25, message: 'Updating packages...' }) + + try { + await Toast.runCommandAndIncrementProgress( + async () => { + await installPackages(root, pythonBinPath, 'dvc') + return 'Upgraded successfully' + }, + progress, + 75 + ) + + return Toast.delayProgressClosing() + } catch (error: unknown) { + return Toast.reportProgressError(error, progress) + } + }) + const showInstallProgress = ( root: string, pythonBinPath: string @@ -52,7 +77,9 @@ const showInstallProgress = ( } }) -export const autoInstallDvc = async (): Promise => { +const getArgsAndRunCommand = async ( + command: (root: string, pythonBinPath: string) => Thenable +): Promise => { const pythonBinPath = await findPythonBinForInstall() const root = getFirstWorkspaceFolder() @@ -68,5 +95,13 @@ export const autoInstallDvc = async (): Promise => { ) } - return showInstallProgress(root, pythonBinPath) + return command(root, pythonBinPath) +} + +export const autoInstallDvc = (): Promise => { + return getArgsAndRunCommand(showInstallProgress) +} + +export const autoUpgradeDvc = (): Promise => { + return getArgsAndRunCommand(showUpgradeProgress) } 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..32f63728e9 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 { @@ -58,6 +59,7 @@ import { getValidInput } from '../vscode/inputBox' import { Title } from '../vscode/title' import { getDVCAppDir } from '../util/appdirs' import { getOptions } from '../cli/dvc/options' +import { isAboveLatestTestedVersion } from '../cli/dvc/version' export class Setup extends BaseRepository @@ -229,7 +231,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 +341,7 @@ export class Setup } await this.accessConfig(cwd, Flag.GLOBAL, DvcConfigKey.STUDIO_TOKEN, token) - return this.updateIsStudioConnected() + return this.updateStudioAndSend() } public getStudioLiveShareToken() { @@ -375,14 +377,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()) @@ -399,12 +415,14 @@ export class Setup cliCompatible: this.getCliCompatible(), dvcCliDetails, hasData, + isAboveLatestTestedVersion: isAboveLatestTestedVersion(this.cliVersion), isPythonExtensionUsed, isStudioConnected: this.studioIsConnected, needsGitCommit, needsGitInitialized, projectInitialized, pythonBinPath: getBinDisplayText(pythonBinPath), + remoteList, sectionCollapsed: collectSectionCollapsed(this.focusedSection), shareLiveToStudio: getConfigValue( ExtensionConfigKey.STUDIO_SHARE_EXPERIMENTS_LIVE @@ -644,16 +662,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 +685,7 @@ export class Setup path.endsWith(join('dvc', 'config')) || path.endsWith(join('dvc', 'config.local')) ) { - void this.updateIsStudioConnected() + void this.updateStudioAndSend() } } ) @@ -705,13 +723,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..2bb21b79ce 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,24 @@ export type SetupData = { needsGitInitialized: boolean | undefined projectInitialized: boolean pythonBinPath: string | undefined + remoteList: RemoteList sectionCollapsed: typeof DEFAULT_SECTION_COLLAPSED | undefined shareLiveToStudio: boolean + isAboveLatestTestedVersion: boolean | undefined } 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..2c6f767478 100644 --- a/extension/src/setup/webview/messages.ts +++ b/extension/src/setup/webview/messages.ts @@ -9,7 +9,7 @@ import { BaseWebview } from '../../webview' import { sendTelemetryEvent } from '../../telemetry' import { EventName } from '../../telemetry/constants' import { selectPythonInterpreter } from '../../extensions/python' -import { autoInstallDvc } from '../autoInstall' +import { autoInstallDvc, autoUpgradeDvc } from '../autoInstall' import { RegisteredCliCommands, RegisteredCommands @@ -40,20 +40,24 @@ export class WebviewMessages { needsGitInitialized, projectInitialized, pythonBinPath, + remoteList, sectionCollapsed, - shareLiveToStudio + shareLiveToStudio, + isAboveLatestTestedVersion }: SetupData) { void this.getWebview()?.show({ canGitInitialize, cliCompatible, dvcCliDetails, hasData, + isAboveLatestTestedVersion, isPythonExtensionUsed, isStudioConnected, needsGitCommit, needsGitInitialized, projectInitialized, pythonBinPath, + remoteList, sectionCollapsed, shareLiveToStudio }) @@ -75,6 +79,8 @@ export class WebviewMessages { return this.selectPythonInterpreter() case MessageFromWebviewType.INSTALL_DVC: return this.installDvc() + case MessageFromWebviewType.UPGRADE_DVC: + return this.upgradeDvc() case MessageFromWebviewType.SETUP_WORKSPACE: return commands.executeCommand( RegisteredCommands.EXTENSION_SETUP_WORKSPACE @@ -127,6 +133,12 @@ export class WebviewMessages { return selectPythonInterpreter() } + private upgradeDvc() { + sendTelemetryEvent(EventName.VIEWS_SETUP_UPGRADE_DVC, undefined, undefined) + + return autoUpgradeDvc() + } + private installDvc() { sendTelemetryEvent(EventName.VIEWS_SETUP_INSTALL_DVC, undefined, undefined) diff --git a/extension/src/telemetry/constants.ts b/extension/src/telemetry/constants.ts index 4d6381757c..da8ef8a87b 100644 --- a/extension/src/telemetry/constants.ts +++ b/extension/src/telemetry/constants.ts @@ -83,6 +83,7 @@ export const EventName = Object.assign( VIEWS_SETUP_SELECT_PYTHON_INTERPRETER: 'views.setup.selectPythonInterpreter', VIEWS_SETUP_SHOW_SCM_FOR_COMMIT: 'views.setup.showScmForCommit', + VIEWS_SETUP_UPGRADE_DVC: 'view.setup.upgradeDvc', VIEWS_TERMINAL_CLOSED: 'views.terminal.closed', VIEWS_TERMINAL_CREATED: 'views.terminal.created', @@ -269,6 +270,7 @@ export interface IEventNamePropertyMapping { [EventName.VIEWS_SETUP_SHOW_SCM_FOR_COMMIT]: undefined [EventName.VIEWS_SETUP_INIT_GIT]: undefined [EventName.VIEWS_SETUP_INSTALL_DVC]: undefined + [EventName.VIEWS_SETUP_UPGRADE_DVC]: undefined [EventName.SETUP_SHOW]: undefined [EventName.SETUP_SHOW_EXPERIMENTS]: undefined diff --git a/extension/src/test/cli/expShow.test.ts b/extension/src/test/cli/expShow.test.ts index 24af86710a..db81cd539c 100644 --- a/extension/src/test/cli/expShow.test.ts +++ b/extension/src/test/cli/expShow.test.ts @@ -5,9 +5,9 @@ import { TEMP_DIR } from './constants' import { dvcReader, initializeDemoRepo, initializeEmptyRepo } from './util' import { dvcDemoPath } from '../util' import { - EXPERIMENT_WORKSPACE_ID, fileHasError, - experimentHasError + experimentHasError, + DEFAULT_EXP_SHOW_OUTPUT } from '../../cli/dvc/contract' import { ExperimentFlag } from '../../cli/dvc/constants' @@ -87,11 +87,7 @@ suite('exp show --show-json', () => { await initializeEmptyRepo() const output = await dvcReader.expShow(TEMP_DIR) - expect(output).to.deep.equal([ - { - rev: EXPERIMENT_WORKSPACE_ID - } - ]) + expect(output).to.deep.equal(DEFAULT_EXP_SHOW_OUTPUT) }) }) }) diff --git a/extension/src/test/suite/experiments/index.test.ts b/extension/src/test/suite/experiments/index.test.ts index 0d6b207d23..84dd0b0090 100644 --- a/extension/src/test/suite/experiments/index.test.ts +++ b/extension/src/test/suite/experiments/index.test.ts @@ -873,7 +873,7 @@ suite('Experiments Test Suite', () => { const queuedId = '90aea7f' const isExperimentSelected = (expId: string): boolean => - !!experimentsModel.getCombinedList().find(({ id }) => id === expId) + !!experimentsModel.getUniqueList().find(({ id }) => id === expId) ?.selected expect( @@ -1101,7 +1101,7 @@ suite('Experiments Test Suite', () => { const areExperimentsStarred = (expIds: string[]): boolean => expIds .map(expId => - experimentsModel.getCombinedList().find(({ id }) => id === expId) + experimentsModel.getUniqueList().find(({ id }) => id === expId) ) .every(exp => exp?.starred) diff --git a/extension/src/test/suite/setup/autoInstall.test.ts b/extension/src/test/suite/setup/autoInstall.test.ts index 775a75fb32..e93b74e28b 100644 --- a/extension/src/test/suite/setup/autoInstall.test.ts +++ b/extension/src/test/suite/setup/autoInstall.test.ts @@ -5,7 +5,7 @@ import { window } from 'vscode' import { Disposable } from '../../../extension' import * as PythonExtension from '../../../extensions/python' import * as Python from '../../../python' -import { autoInstallDvc } from '../../../setup/autoInstall' +import { autoInstallDvc, autoUpgradeDvc } from '../../../setup/autoInstall' import * as WorkspaceFolders from '../../../vscode/workspaceFolders' import { bypassProgressCloseDelay } from '../util' import { Toast } from '../../../vscode/toast' @@ -23,9 +23,98 @@ suite('Auto Install Test Suite', () => { disposable.dispose() }) - describe('autoInstallDvc', () => { - const defaultPython = getDefaultPython() + const defaultPython = getDefaultPython() + + describe('autoUpgradeDvc', () => { + it('should return early if no Python interpreter is found', async () => { + stub(PythonExtension, 'getPythonExecutionDetails').resolves(undefined) + stub(Python, 'findPythonBin').resolves(undefined) + const mockInstallPackages = stub(Python, 'installPackages').resolves( + undefined + ) + + const showProgressSpy = spy(window, 'withProgress') + const showErrorSpy = spy(window, 'showErrorMessage') + + await autoUpgradeDvc() + + expect(showProgressSpy).not.to.be.called + expect(showErrorSpy).to.be.called + expect(mockInstallPackages).not.to.be.called + }) + + it('should return early if there is no workspace folder open', async () => { + stub(PythonExtension, 'getPythonExecutionDetails').resolves(undefined) + stub(Python, 'findPythonBin').resolves(defaultPython) + const mockInstallPackages = stub(Python, 'installPackages').resolves( + undefined + ) + stub(WorkspaceFolders, 'getFirstWorkspaceFolder').returns(undefined) + const showProgressSpy = spy(window, 'withProgress') + const showErrorSpy = spy(window, 'showErrorMessage') + + await autoUpgradeDvc() + + expect(showProgressSpy).not.to.be.called + expect(showErrorSpy).to.be.called + expect(mockInstallPackages).not.to.be.called + }) + + it('should install DVC if a Python interpreter is found', async () => { + bypassProgressCloseDelay() + const cwd = __dirname + stub(PythonExtension, 'getPythonExecutionDetails').resolves(undefined) + stub(Python, 'findPythonBin').resolves(defaultPython) + const mockInstallPackages = stub(Python, 'installPackages').resolves( + undefined + ) + stub(WorkspaceFolders, 'getFirstWorkspaceFolder').returns(cwd) + + const showProgressSpy = spy(window, 'withProgress') + const showErrorSpy = spy(window, 'showErrorMessage') + + await autoUpgradeDvc() + + expect(showProgressSpy).to.be.called + expect(showErrorSpy).not.to.be.called + expect(mockInstallPackages).to.be.called + expect(mockInstallPackages).to.be.calledWithExactly( + cwd, + defaultPython, + 'dvc' + ) + }) + + it('should show an error message if DVC fails to install', async () => { + bypassProgressCloseDelay() + const cwd = __dirname + stub(PythonExtension, 'getPythonExecutionDetails').resolves(undefined) + stub(Python, 'findPythonBin').resolves(defaultPython) + const mockInstallPackages = stub(Python, 'installPackages') + .onFirstCall() + .rejects(new Error('Network error')) + stub(WorkspaceFolders, 'getFirstWorkspaceFolder').returns(cwd) + + const showProgressSpy = spy(window, 'withProgress') + const showErrorSpy = spy(window, 'showErrorMessage') + const reportProgressErrorSpy = spy(Toast, 'reportProgressError') + + await autoUpgradeDvc() + + expect(showProgressSpy).to.be.called + expect(showErrorSpy).not.to.be.called + expect(reportProgressErrorSpy).to.be.calledOnce + expect(mockInstallPackages).to.be.called + expect(mockInstallPackages).to.be.calledWithExactly( + cwd, + defaultPython, + 'dvc' + ) + }) + }) + + describe('autoInstallDvc', () => { it('should return early if no Python interpreter is found', async () => { stub(PythonExtension, 'getPythonExecutionDetails').resolves(undefined) stub(Python, 'findPythonBin').resolves(undefined) diff --git a/extension/src/test/suite/setup/index.test.ts b/extension/src/test/suite/setup/index.test.ts index ee0fc2df89..9ef5143756 100644 --- a/extension/src/test/suite/setup/index.test.ts +++ b/extension/src/test/suite/setup/index.test.ts @@ -138,6 +138,22 @@ suite('Setup Test Suite', () => { expect(mockAutoInstallDvc).to.be.calledOnce }).timeout(WEBVIEW_TEST_TIMEOUT) + it('should handle an auto upgrade dvc message from the webview', async () => { + const { messageSpy, setup, mockAutoUpgradeDvc } = buildSetup(disposable) + + const webview = await setup.showWebview() + await webview.isReady() + + const mockMessageReceived = getMessageReceivedEmitter(webview) + + messageSpy.resetHistory() + mockMessageReceived.fire({ + type: MessageFromWebviewType.UPGRADE_DVC + }) + + expect(mockAutoUpgradeDvc).to.be.calledOnce + }).timeout(WEBVIEW_TEST_TIMEOUT) + it('should handle a select Python interpreter message from the webview', async () => { const { messageSpy, mockExecuteCommand, setup } = buildSetup(disposable) const setInterpreterCommand = 'python.setInterpreter' @@ -240,12 +256,14 @@ suite('Setup Test Suite', () => { cliCompatible: undefined, dvcCliDetails: { command: 'dvc', version: undefined }, hasData: false, + isAboveLatestTestedVersion: undefined, isPythonExtensionUsed: false, isStudioConnected: false, needsGitCommit: true, needsGitInitialized: true, projectInitialized: false, pythonBinPath: undefined, + remoteList: undefined, sectionCollapsed: undefined, shareLiveToStudio: false }) @@ -281,12 +299,14 @@ suite('Setup Test Suite', () => { cliCompatible: true, dvcCliDetails: { command: 'dvc', version: MIN_CLI_VERSION }, hasData: false, + isAboveLatestTestedVersion: false, isPythonExtensionUsed: false, isStudioConnected: false, needsGitCommit: true, needsGitInitialized: true, projectInitialized: false, pythonBinPath: undefined, + remoteList: undefined, sectionCollapsed: undefined, shareLiveToStudio: false }) @@ -331,12 +351,14 @@ suite('Setup Test Suite', () => { version: MIN_CLI_VERSION }, hasData: false, + isAboveLatestTestedVersion: false, isPythonExtensionUsed: false, isStudioConnected: false, needsGitCommit: false, needsGitInitialized: false, projectInitialized: false, pythonBinPath: undefined, + remoteList: undefined, sectionCollapsed: undefined, shareLiveToStudio: false }) @@ -381,12 +403,14 @@ suite('Setup Test Suite', () => { version: MIN_CLI_VERSION }, hasData: false, + isAboveLatestTestedVersion: false, isPythonExtensionUsed: false, isStudioConnected: false, needsGitCommit: true, 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..fd0d8a2405 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 @@ -61,6 +62,7 @@ export const buildSetup = ( ) const mockAutoInstallDvc = stub(AutoInstall, 'autoInstallDvc') + const mockAutoUpgradeDvc = stub(AutoInstall, 'autoUpgradeDvc') stub(AutoInstall, 'findPythonBinForInstall').resolves(undefined) const mockShowWebview = stub(WorkspaceExperiments.prototype, 'showWebview') @@ -104,6 +106,7 @@ export const buildSetup = ( internalCommands, messageSpy, mockAutoInstallDvc, + mockAutoUpgradeDvc, mockExecuteCommand, mockGetGitRepositoryRoot, mockGlobalVersion, diff --git a/extension/src/webview/contract.ts b/extension/src/webview/contract.ts index f1f5eda5e8..fbf109194b 100644 --- a/extension/src/webview/contract.ts +++ b/extension/src/webview/contract.ts @@ -62,6 +62,7 @@ export enum MessageFromWebviewType { INITIALIZE_GIT = 'initialize-git', SHOW_SCM_PANEL = 'show-scm-panel', INSTALL_DVC = 'install-dvc', + UPGRADE_DVC = 'upgrade-dvc', SETUP_WORKSPACE = 'setup-workspace', ZOOM_PLOT = 'zoom-plot', SHOW_MORE_COMMITS = 'show-more-commits', @@ -214,6 +215,7 @@ export type MessageFromWebview = | { type: MessageFromWebviewType.INITIALIZE_GIT } | { type: MessageFromWebviewType.SHOW_SCM_PANEL } | { type: MessageFromWebviewType.INSTALL_DVC } + | { type: MessageFromWebviewType.UPGRADE_DVC } | { type: MessageFromWebviewType.SETUP_WORKSPACE } | { type: MessageFromWebviewType.OPEN_STUDIO } | { type: MessageFromWebviewType.OPEN_STUDIO_PROFILE } diff --git a/webview/icons/codicons.mjs b/webview/icons/codicons.mjs index 72b4c6951e..b12ef06b30 100644 --- a/webview/icons/codicons.mjs +++ b/webview/icons/codicons.mjs @@ -24,5 +24,6 @@ export const codicons = [ 'sort-precedence', 'star-empty', 'star-full', - 'trash' + 'trash', + 'warning' ] diff --git a/webview/src/plots/components/App.test.tsx b/webview/src/plots/components/App.test.tsx index d0a11732aa..b79f99f218 100644 --- a/webview/src/plots/components/App.test.tsx +++ b/webview/src/plots/components/App.test.tsx @@ -53,7 +53,7 @@ import { dragEnter, dragLeave } from '../../test/dragDrop' -import { SectionDescription } from '../../shared/components/sectionContainer/SectionContainer' +import { SectionDescriptionMainText } from '../../shared/components/sectionContainer/SectionDescription' import { DragEnterDirection } from '../../shared/components/dragDrop/util' import { clearSelection, createWindowTextSelection } from '../../test/selection' import * as EventCurrentTargetDistances from '../../shared/components/dragDrop/currentTarget' @@ -648,7 +648,7 @@ describe('App', () => { const summaryElement = await screen.findByText('Custom') createWindowTextSelection( // eslint-disable-next-line testing-library/no-node-access - SectionDescription['custom-plots'].props.children, + SectionDescriptionMainText['custom-plots'].props.children, 2 ) fireEvent.click(summaryElement, { diff --git a/webview/src/setup/components/App.test.tsx b/webview/src/setup/components/App.test.tsx index 13a7d57cdf..672b161cc4 100644 --- a/webview/src/setup/components/App.test.tsx +++ b/webview/src/setup/components/App.test.tsx @@ -31,12 +31,14 @@ const DEFAULT_DATA = { version: '1.0.0' }, hasData: false, + isAboveLatestTestedVersion: false, isPythonExtensionUsed: false, isStudioConnected: false, needsGitCommit: false, needsGitInitialized: false, projectInitialized: true, pythonBinPath: undefined, + remoteList: undefined, sectionCollapsed: undefined, shareLiveToStudio: false } @@ -95,6 +97,9 @@ describe('App', () => { }) expect(screen.getByText('DVC is incompatible')).toBeInTheDocument() + expect( + screen.getByText('Please update your install and try again.') + ).toBeInTheDocument() const button = screen.getByText('Check Compatibility') expect(button).toBeInTheDocument() @@ -105,18 +110,27 @@ describe('App', () => { }) }) - it('should show a screen saying that DVC is not installed if the cli is unavailable', () => { + it('should tell the user than they can auto upgrade DVC if DVC is incompatible and python is available', () => { renderApp({ - cliCompatible: undefined, + cliCompatible: false, dvcCliDetails: { command: 'dvc', - version: undefined - } + version: '1.0.0' + }, + pythonBinPath: 'python' }) - expect(screen.getAllByText('DVC is currently unavailable')).toHaveLength( - 2 - ) + expect(screen.getByText('DVC is incompatible')).toBeInTheDocument() + + const compatibityButton = screen.getByText('Check Compatibility') + expect(compatibityButton).toBeInTheDocument() + const upgradeButton = screen.getByText('Upgrade (pip)') + expect(upgradeButton).toBeInTheDocument() + + fireEvent.click(upgradeButton) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: MessageFromWebviewType.UPGRADE_DVC + }) }) it('should tell the user they cannot install DVC without a Python interpreter', () => { @@ -221,7 +235,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 +263,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 +294,9 @@ describe('App', () => { projectInitialized: false, sectionCollapsed: { [SetupSection.DVC]: false, - [SetupSection.STUDIO]: true, - [SetupSection.EXPERIMENTS]: true + [SetupSection.EXPERIMENTS]: true, + [SetupSection.REMOTES]: true, + [SetupSection.STUDIO]: true } } @@ -393,7 +407,9 @@ describe('App', () => { projectInitialized: false }) - const iconWrapper = screen.getAllByTestId('info-tooltip-toggle')[0] + const iconWrapper = within( + screen.getByTestId('dvc-section-details') + ).getByTestId('info-tooltip-toggle') expect( within(iconWrapper).getByTestId(TooltipIconType.ERROR) @@ -401,14 +417,39 @@ 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] + const iconWrapper = within( + screen.getByTestId('dvc-section-details') + ).getByTestId('info-tooltip-toggle') expect( within(iconWrapper).getByTestId(TooltipIconType.PASSED) ).toBeInTheDocument() }) + + it('should add a warning icon and message if version is above the latest tested version', () => { + renderApp({ + isAboveLatestTestedVersion: true + }) + + const iconWrapper = within( + screen.getByTestId('dvc-section-details') + ).getByTestId('info-tooltip-toggle') + + expect( + within(iconWrapper).getByTestId(TooltipIconType.WARNING) + ).toBeInTheDocument() + + fireEvent.mouseEnter(iconWrapper) + + expect( + screen.getByText( + 'The located version has not been tested against the extension. If you are experiencing unexpected behaviour, first try to update the extension. If there are no updates available, please downgrade DVC to the same minor version as displayed and reload the window.' + ) + ).toBeInTheDocument() + }) }) describe('Experiments', () => { @@ -422,7 +463,8 @@ describe('App', () => { 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') @@ -496,7 +538,9 @@ describe('App', () => { it('should show an error icon if experiments are not setup', () => { renderApp() - const iconWrapper = screen.getAllByTestId('info-tooltip-toggle')[1] + const iconWrapper = within( + screen.getByTestId('experiments-section-details') + ).getByTestId('info-tooltip-toggle') expect( within(iconWrapper).getByTestId(TooltipIconType.ERROR) @@ -508,7 +552,9 @@ describe('App', () => { cliCompatible: false }) - const iconWrapper = screen.getAllByTestId('info-tooltip-toggle')[1] + const iconWrapper = within( + screen.getByTestId('experiments-section-details') + ).getByTestId('info-tooltip-toggle') expect( within(iconWrapper).getByTestId(TooltipIconType.ERROR) @@ -520,7 +566,9 @@ describe('App', () => { hasData: true }) - const iconWrapper = screen.getAllByTestId('info-tooltip-toggle')[1] + const iconWrapper = within( + screen.getByTestId('experiments-section-details') + ).getByTestId('info-tooltip-toggle') expect( within(iconWrapper).getByTestId(TooltipIconType.PASSED) @@ -578,7 +626,9 @@ describe('App', () => { cliCompatible: false }) - const iconWrapper = screen.getAllByTestId('info-tooltip-toggle')[2] + const iconWrapper = within( + screen.getByTestId('studio-section-details') + ).getByTestId('info-tooltip-toggle') expect( within(iconWrapper).getByTestId(TooltipIconType.ERROR) @@ -588,7 +638,9 @@ 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 = within( + screen.getByTestId('studio-section-details') + ).getByTestId('info-tooltip-toggle') expect( within(iconWrapper).getByTestId(TooltipIconType.INFO) @@ -629,7 +681,9 @@ describe('App', () => { isStudioConnected: true }) - const iconWrapper = screen.getAllByTestId('info-tooltip-toggle')[2] + const iconWrapper = within( + screen.getByTestId('studio-section-details') + ).getByTestId('info-tooltip-toggle') expect( within(iconWrapper).getByTestId(TooltipIconType.PASSED) @@ -646,9 +700,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 +722,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 +744,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 +762,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..369408b0cd 100644 --- a/webview/src/setup/components/App.tsx +++ b/webview/src/setup/components/App.tsx @@ -9,31 +9,55 @@ 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 { TooltipIconType } from '../../shared/components/sectionContainer/InfoTooltip' import { SetupDispatch, SetupState } from '../store' import { updateSectionCollapsed, updateHasData as updateWebviewHasData } from '../state/webviewSlice' import { - updateCanGitInitalized, + updateCanGitInitialize, updateCliCompatible, updateDvcCliDetails, + updateIsAboveLatestTestedVersion, updateIsPythonExtensionUsed, updateNeedsGitInitialized, 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' + +const getDvcStatusIcon = ( + isDvcSetup: boolean, + isVersionAboveLatestTested: boolean +) => { + if (!isDvcSetup) { + return TooltipIconType.ERROR + } + + return isVersionAboveLatestTested + ? TooltipIconType.WARNING + : TooltipIconType.PASSED +} + +const getStudioStatusIcon = (cliCompatible: boolean, isConnected: boolean) => { + if (!cliCompatible) { + return TooltipIconType.ERROR + } + return isConnected ? TooltipIconType.PASSED : TooltipIconType.INFO +} export const feedStore = ( data: MessageToWebview, dispatch: SetupDispatch @@ -45,7 +69,7 @@ export const feedStore = ( for (const key of Object.keys(data.data)) { switch (key) { case 'canGitInitialize': - dispatch(updateCanGitInitalized(data.data.canGitInitialize)) + dispatch(updateCanGitInitialize(data.data.canGitInitialize)) continue case 'cliCompatible': dispatch(updateCliCompatible(data.data.cliCompatible)) @@ -59,6 +83,11 @@ export const feedStore = ( case 'isPythonExtensionUsed': dispatch(updateIsPythonExtensionUsed(data.data.isPythonExtensionUsed)) continue + case 'isAboveLatestTestedVersion': + dispatch( + updateIsAboveLatestTestedVersion(data.data.isAboveLatestTestedVersion) + ) + continue case 'isStudioConnected': dispatch(updateIsStudioConnected(data.data.isStudioConnected)) continue @@ -80,6 +109,10 @@ export const feedStore = ( case 'shareLiveToStudio': dispatch(updateShareLiveToStudio(data.data.shareLiveToStudio)) continue + case 'remoteList': + dispatch(updateRemoteList(data.data.remoteList)) + continue + default: continue } @@ -87,12 +120,13 @@ export const feedStore = ( } export const App: React.FC = () => { - const { projectInitialized, cliCompatible } = useSelector( - (state: SetupState) => state.dvc - ) + const { projectInitialized, cliCompatible, isAboveLatestTestedVersion } = + useSelector((state: SetupState) => state.dvc) const hasExperimentsData = useSelector( (state: SetupState) => state.experiments.hasData ) + const { remoteList } = useSelector((state: SetupState) => state.remote) + const isStudioConnected = useSelector( (state: SetupState) => state.studio.isStudioConnected ) @@ -123,22 +157,46 @@ export const App: React.FC = () => { + The located version has not been tested against the extension. If + you are experiencing unexpected behaviour, first try to update the + extension. If there are no updates available, please downgrade DVC + to the same minor version as displayed and reload the window. + + ) : undefined + } > + + + { - if (!isSetup) { - return TooltipIconType.ERROR - } - - return isConnected ? TooltipIconType.PASSED : TooltipIconType.INFO -} - export const SetupContainer: React.FC<{ children: React.ReactNode sectionKey: SetupSection title: string - isSetup: boolean - isConnected?: boolean -}> = ({ children, sectionKey, title, isSetup, isConnected }) => { + icon: TooltipIconType + overrideSectionDescription?: JSX.Element +}> = ({ children, sectionKey, title, icon, overrideSectionDescription }) => { const sectionCollapsed = useSelector( (state: SetupState) => state.webview.sectionCollapsed ) @@ -31,8 +23,9 @@ export const SetupContainer: React.FC<{ sectionCollapsed={sectionCollapsed[sectionKey]} sectionKey={sectionKey} title={title} - icon={getTooltipIconType(isSetup, isConnected)} + icon={icon} onToggleSection={() => dispatch(toggleSectionCollapsed(sectionKey))} + overrideSectionDescription={overrideSectionDescription} > {children} diff --git a/webview/src/setup/components/dvc/CliIncompatible.tsx b/webview/src/setup/components/dvc/CliIncompatible.tsx index 584cb8586c..6b9a81cbcc 100644 --- a/webview/src/setup/components/dvc/CliIncompatible.tsx +++ b/webview/src/setup/components/dvc/CliIncompatible.tsx @@ -1,15 +1,38 @@ import React, { PropsWithChildren } from 'react' +import { useSelector } from 'react-redux' +import styles from './styles.module.scss' import { EmptyState } from '../../../shared/components/emptyState/EmptyState' import { Button } from '../../../shared/components/button/Button' -import { checkCompatibility } from '../messages' +import { checkCompatibility, upgradeDvc } from '../messages' +import { SetupState } from '../../store' -export const CliIncompatible: React.FC = ({ children }) => ( - -
-

DVC is incompatible

- {children} +export const CliIncompatible: React.FC = ({ children }) => { + const pythonBinPath = useSelector( + (state: SetupState) => state.dvc.pythonBinPath + ) + const canUpgrade = !!pythonBinPath + + const conditionalContents = canUpgrade ? ( + <> +
+
+ + ) : ( + <>

Please update your install and try again.

-
-) + + ) + + return ( + +
+

DVC is incompatible

+ {children} + {conditionalContents} +
+
+ ) +} diff --git a/webview/src/setup/components/dvc/Dvc.tsx b/webview/src/setup/components/dvc/Dvc.tsx index e49ebc173b..d003cd128d 100644 --- a/webview/src/setup/components/dvc/Dvc.tsx +++ b/webview/src/setup/components/dvc/Dvc.tsx @@ -7,8 +7,8 @@ import { ProjectUninitialized } from './ProjectUninitialized' import { CliUnavailable } from './CliUnavailable' import { EmptyState } from '../../../shared/components/emptyState/EmptyState' import { usePrevious } from '../../hooks/usePrevious' -import { updateSectionCollapsed } from '../../state/webviewSlice' import { SetupState } from '../../store' +import { closeSection } from '../shared/util' export const Dvc: 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.

-