diff --git a/extension/package.json b/extension/package.json index 5e4b42e67e..8c080cc6ad 100644 --- a/extension/package.json +++ b/extension/package.json @@ -399,16 +399,6 @@ "command": "dvc.showCommands", "category": "DVC" }, - { - "title": "Connect to Studio", - "command": "dvc.showConnect", - "category": "DVC" - }, - { - "title": "Open Studio Settings", - "command": "dvc.showStudioSettings", - "category": "DVC" - }, { "title": "Show Experiments", "command": "dvc.showExperiments", @@ -819,14 +809,6 @@ "command": "dvc.stopQueuedExperiments", "when": "dvc.commands.available && dvc.project.available" }, - { - "command": "dvc.showConnect", - "when": "dvc.commands.available && dvc.project.available && !dvc.studio.connected" - }, - { - "command": "dvc.showStudioSettings", - "when": "dvc.commands.available && dvc.project.available && dvc.studio.connected" - }, { "command": "dvc.selectForCompare", "when": "false" @@ -1416,12 +1398,12 @@ }, { "view": "dvc.views.studio", - "contents": "[$(plug) Connect](command:dvc.showConnect)", + "contents": "[$(plug) Connect](command:dvc.showSetup)", "when": "!dvc.studio.connected" }, { "view": "dvc.views.studio", - "contents": "[$(settings-gear) Open Settings](command:dvc.showConnect)", + "contents": "[$(settings-gear) Open Settings](command:dvc.showSetup)", "when": "dvc.studio.connected" }, { diff --git a/extension/src/commands/external.ts b/extension/src/commands/external.ts index 22155248e7..1cb05ebdf3 100644 --- a/extension/src/commands/external.ts +++ b/extension/src/commands/external.ts @@ -96,8 +96,6 @@ export enum RegisteredCommands { SETUP_SHOW = 'dvc.showSetup', SELECT_FOCUSED_PROJECTS = 'dvc.selectFocusedProjects', - CONNECT_SHOW = 'dvc.showConnect', - OPEN_STUDIO_SETTINGS = 'dvc.showStudioSettings', ADD_STUDIO_ACCESS_TOKEN = 'dvc.addStudioAccessToken', UPDATE_STUDIO_ACCESS_TOKEN = 'dvc.updateStudioAccessToken', REMOVE_STUDIO_ACCESS_TOKEN = 'dvc.removeStudioAccessToken', diff --git a/extension/src/connect/index.ts b/extension/src/connect/index.ts deleted file mode 100644 index 90c936e7cd..0000000000 --- a/extension/src/connect/index.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { commands, ExtensionContext, SecretStorage, workspace } from 'vscode' -import { validateTokenInput } from './inputBox' -import { STUDIO_ACCESS_TOKEN_KEY, isStudioAccessToken } from './token' -import { ConnectData, STUDIO_URL } from './webview/contract' -import { Resource } from '../resourceLocator' -import { ViewKey } from '../webview/constants' -import { MessageFromWebview, MessageFromWebviewType } from '../webview/contract' -import { BaseRepository } from '../webview/repository' -import { Logger } from '../common/logger' -import { getValidInput } from '../vscode/inputBox' -import { Title } from '../vscode/title' -import { openUrl } from '../vscode/external' -import { ContextKey, setContextValue } from '../vscode/context' -import { RegisteredCommands } from '../commands/external' -import { GLOBAL_WEBVIEW_DVCROOT } from '../webview/factory' -import { ConfigKey, getConfigValue, setConfigValue } from '../vscode/config' - -export class Connect extends BaseRepository { - public readonly viewKey = ViewKey.CONNECT - - private readonly secrets: SecretStorage - private studioAccessToken: string | undefined = undefined - private studioIsConnected = false - - constructor(context: ExtensionContext, webviewIcon: Resource) { - super(GLOBAL_WEBVIEW_DVCROOT, webviewIcon) - - this.secrets = context.secrets - - this.dispose.track( - this.onDidReceivedWebviewMessage(message => - this.handleMessageFromWebview(message) - ) - ) - - void this.getSecret(STUDIO_ACCESS_TOKEN_KEY).then( - async studioAccessToken => { - this.studioAccessToken = studioAccessToken - await this.updateIsStudioConnected() - this.deferred.resolve() - } - ) - - this.dispose.track( - context.secrets.onDidChange(async e => { - if (e.key !== STUDIO_ACCESS_TOKEN_KEY) { - return - } - - this.studioAccessToken = await this.getSecret(STUDIO_ACCESS_TOKEN_KEY) - return this.updateIsStudioConnected() - }) - ) - - this.dispose.track( - workspace.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ConfigKey.STUDIO_SHARE_EXPERIMENTS_LIVE)) { - this.sendWebviewMessage() - } - }) - ) - } - - public sendInitialWebviewData() { - return this.sendWebviewMessage() - } - - public removeStudioAccessToken() { - return this.removeSecret(STUDIO_ACCESS_TOKEN_KEY) - } - - public async saveStudioAccessToken() { - const token = await getValidInput( - Title.ENTER_STUDIO_TOKEN, - validateTokenInput, - { password: true } - ) - if (!token) { - return - } - - return this.storeSecret(STUDIO_ACCESS_TOKEN_KEY, token) - } - - public getStudioLiveShareToken() { - return getConfigValue( - ConfigKey.STUDIO_SHARE_EXPERIMENTS_LIVE, - false - ) - ? this.getStudioAccessToken() - : undefined - } - - public getStudioAccessToken() { - return this.studioAccessToken - } - - private sendWebviewMessage() { - void this.getWebview()?.show({ - isStudioConnected: this.studioIsConnected, - shareLiveToStudio: getConfigValue(ConfigKey.STUDIO_SHARE_EXPERIMENTS_LIVE) - }) - } - - private handleMessageFromWebview(message: MessageFromWebview) { - switch (message.type) { - case MessageFromWebviewType.OPEN_STUDIO: - return this.openStudio() - case MessageFromWebviewType.OPEN_STUDIO_PROFILE: - return this.openStudioProfile() - case MessageFromWebviewType.SAVE_STUDIO_TOKEN: - return commands.executeCommand( - RegisteredCommands.ADD_STUDIO_ACCESS_TOKEN - ) - case MessageFromWebviewType.REMOVE_STUDIO_TOKEN: - return commands.executeCommand( - RegisteredCommands.REMOVE_STUDIO_ACCESS_TOKEN - ) - case MessageFromWebviewType.SET_STUDIO_SHARE_EXPERIMENTS_LIVE: - return setConfigValue( - ConfigKey.STUDIO_SHARE_EXPERIMENTS_LIVE, - message.payload - ) - default: - Logger.error(`Unexpected message: ${JSON.stringify(message)}`) - } - } - - private openStudio() { - return openUrl(STUDIO_URL) - } - - private openStudioProfile() { - return openUrl(`${STUDIO_URL}/user/_/profile?section=accessToken`) - } - - private updateIsStudioConnected() { - const storedToken = this.getStudioAccessToken() - const isConnected = isStudioAccessToken(storedToken) - return this.setStudioIsConnected(isConnected) - } - - private setStudioIsConnected(isConnected: boolean) { - this.studioIsConnected = isConnected - this.sendWebviewMessage() - return setContextValue(ContextKey.STUDIO_CONNECTED, isConnected) - } - - private getSecret(key: string) { - const secrets = this.getSecrets() - return secrets.get(key) - } - - private storeSecret(key: string, value: string) { - const secrets = this.getSecrets() - return secrets.store(key, value) - } - - private removeSecret(key: string) { - const secrets = this.getSecrets() - return secrets.delete(key) - } - - private getSecrets() { - return this.secrets - } -} diff --git a/extension/src/connect/register.ts b/extension/src/connect/register.ts deleted file mode 100644 index f8ebc756df..0000000000 --- a/extension/src/connect/register.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Connect } from '.' -import { RegisteredCommands } from '../commands/external' -import { InternalCommands } from '../commands/internal' - -export const registerConnectCommands = ( - connect: Connect, - internalCommands: InternalCommands -): void => { - internalCommands.registerExternalCommand( - RegisteredCommands.CONNECT_SHOW, - () => connect.showWebview() - ) - - internalCommands.registerExternalCommand( - RegisteredCommands.OPEN_STUDIO_SETTINGS, - () => connect.showWebview() - ) - - internalCommands.registerExternalCommand( - RegisteredCommands.ADD_STUDIO_ACCESS_TOKEN, - () => connect.saveStudioAccessToken() - ) - - internalCommands.registerExternalCommand( - RegisteredCommands.UPDATE_STUDIO_ACCESS_TOKEN, - () => connect.saveStudioAccessToken() - ) - - internalCommands.registerExternalCommand( - RegisteredCommands.REMOVE_STUDIO_ACCESS_TOKEN, - () => connect.removeStudioAccessToken() - ) -} diff --git a/extension/src/connect/webview/contract.ts b/extension/src/connect/webview/contract.ts deleted file mode 100644 index 1cc71b06fd..0000000000 --- a/extension/src/connect/webview/contract.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const STUDIO_URL = 'https://studio.iterative.ai' - -export type ConnectData = { - isStudioConnected: boolean - shareLiveToStudio: boolean -} diff --git a/extension/src/experiments/commands/index.ts b/extension/src/experiments/commands/index.ts index fd7b1b68e6..52906fee2f 100644 --- a/extension/src/experiments/commands/index.ts +++ b/extension/src/experiments/commands/index.ts @@ -2,7 +2,7 @@ import { Progress, commands } from 'vscode' import { AvailableCommands, InternalCommands } from '../../commands/internal' import { Toast } from '../../vscode/toast' import { WorkspaceExperiments } from '../workspace' -import { Connect } from '../../connect' +import { Setup } from '../../setup' import { RegisteredCommands } from '../../commands/external' export const getBranchExperimentCommand = @@ -108,11 +108,11 @@ export const getShareExperimentAsCommitCommand = } export const getShareExperimentToStudioCommand = - (internalCommands: InternalCommands, connect: Connect) => + (internalCommands: InternalCommands, setup: Setup) => ({ dvcRoot, id }: { dvcRoot: string; id: string }) => { - const studioAccessToken = connect.getStudioAccessToken() + const studioAccessToken = setup.getStudioAccessToken() if (!studioAccessToken) { - return commands.executeCommand(RegisteredCommands.CONNECT_SHOW) + return commands.executeCommand(RegisteredCommands.SETUP_SHOW) } return internalCommands.executeCommand( diff --git a/extension/src/experiments/commands/register.ts b/extension/src/experiments/commands/register.ts index 0973e87206..1b8326cd05 100644 --- a/extension/src/experiments/commands/register.ts +++ b/extension/src/experiments/commands/register.ts @@ -15,7 +15,6 @@ import { Title } from '../../vscode/title' import { Context, getDvcRootFromContext } from '../../vscode/context' import { Setup } from '../../setup' import { showSetupOrExecuteCommand } from '../../commands/util' -import { Connect } from '../../connect' type ExperimentDetails = { dvcRoot: string; id: string } @@ -331,8 +330,7 @@ const registerExperimentRunCommands = ( export const registerExperimentCommands = ( experiments: WorkspaceExperiments, internalCommands: InternalCommands, - setup: Setup, - connect: Connect + setup: Setup ) => { registerExperimentCwdCommands(experiments, internalCommands) registerExperimentNameCommands(experiments, internalCommands) @@ -348,7 +346,7 @@ export const registerExperimentCommands = ( internalCommands.registerExternalCommand( RegisteredCommands.EXPERIMENT_VIEW_SHARE_TO_STUDIO, - getShareExperimentToStudioCommand(internalCommands, connect) + getShareExperimentToStudioCommand(internalCommands, setup) ) internalCommands.registerExternalCliCommand( diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 0e03ec6d01..16081d01b3 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -9,8 +9,6 @@ import { DvcExecutor } from './cli/dvc/executor' import { DvcRunner } from './cli/dvc/runner' import { DvcReader } from './cli/dvc/reader' import { Config } from './config' -import { Connect } from './connect' -import { registerConnectCommands } from './connect/register' import { Context } from './context' import { WorkspaceExperiments } from './experiments/workspace' import { registerExperimentCommands } from './experiments/commands/register' @@ -65,7 +63,6 @@ export class Extension extends Disposable { private readonly resourceLocator: ResourceLocator private readonly repositories: WorkspaceRepositories - private readonly connect: Connect private readonly experiments: WorkspaceExperiments private readonly plots: WorkspacePlots private readonly setup: Setup @@ -94,14 +91,10 @@ export class Extension extends Disposable { const config = this.dispose.track(new Config()) - this.connect = this.dispose.track( - new Connect(context, this.resourceLocator.dvcIcon) - ) - this.gitExecutor = this.dispose.track(new GitExecutor()) this.gitReader = this.dispose.track(new GitReader()) - const getStudioLiveShareToken = () => this.connect.getStudioLiveShareToken() + const getStudioLiveShareToken = () => this.setup.getStudioLiveShareToken() this.dvcExecutor = this.dispose.track( new DvcExecutor(config, getStudioLiveShareToken, cwd => @@ -189,6 +182,7 @@ export class Extension extends Disposable { this.setup = this.dispose.track( new Setup( + context, config, this.internalCommands, this.experiments, @@ -207,14 +201,11 @@ export class Extension extends Disposable { ) ) - registerConnectCommands(this.connect, this.internalCommands) - registerPatchCommand(this.internalCommands) registerExperimentCommands( this.experiments, this.internalCommands, - this.setup, - this.connect + this.setup ) registerPlotsCommands(this.plots, this.internalCommands, this.setup) registerSetupCommands(this.setup, this.internalCommands) diff --git a/extension/src/patch.ts b/extension/src/patch.ts index 6eefba4ffa..c5025657f3 100644 --- a/extension/src/patch.ts +++ b/extension/src/patch.ts @@ -104,7 +104,7 @@ const showUnauthorized = async () => { UserResponse.SHOW ) if (response === UserResponse.SHOW) { - return commands.executeCommand(RegisteredCommands.CONNECT_SHOW) + return commands.executeCommand(RegisteredCommands.SETUP_SHOW) } } diff --git a/extension/src/setup/index.ts b/extension/src/setup/index.ts index 053db1d4de..a5105f7a73 100644 --- a/extension/src/setup/index.ts +++ b/extension/src/setup/index.ts @@ -1,10 +1,19 @@ -import { Event, EventEmitter, ViewColumn } from 'vscode' +import { + Event, + EventEmitter, + ExtensionContext, + SecretStorage, + ViewColumn, + workspace +} from 'vscode' import { Disposable, Disposer } from '@hediet/std/disposable' import isEmpty from 'lodash.isempty' import { SetupData as TSetupData } from './webview/contract' import { WebviewMessages } from './webview/messages' +import { validateTokenInput } from './inputBox' import { findPythonBinForInstall } from './autoInstall' import { run, runWithRecheck, runWorkspace } from './runner' +import { STUDIO_ACCESS_TOKEN_KEY, isStudioAccessToken } from './token' import { pickFocusedProjects } from './quickPick' import { BaseWebview } from '../webview' import { ViewKey } from '../webview/constants' @@ -39,6 +48,9 @@ import { WorkspaceScale } from '../telemetry/collect' import { gitPath } from '../cli/git/constants' import { DOT_DVC } from '../cli/dvc/constants' import { GLOBAL_WEBVIEW_DVCROOT } from '../webview/factory' +import { ConfigKey, getConfigValue } from '../vscode/config' +import { getValidInput } from '../vscode/inputBox' +import { Title } from '../vscode/title' export type SetupWebviewWebview = BaseWebview @@ -74,7 +86,12 @@ export class Setup private dotFolderWatcher?: Disposer + private readonly secrets: SecretStorage + private studioAccessToken: string | undefined = undefined + private studioIsConnected = false + constructor( + context: ExtensionContext, config: Config, internalCommands: InternalCommands, experiments: WorkspaceExperiments, @@ -127,6 +144,35 @@ export class Setup this.watchConfigurationDetailsForChanges() this.watchDotFolderForChanges() this.watchPathForChanges(stopWatch) + + this.secrets = context.secrets + + void this.getSecret(STUDIO_ACCESS_TOKEN_KEY).then( + async studioAccessToken => { + this.studioAccessToken = studioAccessToken + await this.updateIsStudioConnected() + this.deferred.resolve() + } + ) + + this.dispose.track( + context.secrets.onDidChange(async e => { + if (e.key !== STUDIO_ACCESS_TOKEN_KEY) { + return + } + + this.studioAccessToken = await this.getSecret(STUDIO_ACCESS_TOKEN_KEY) + return this.updateIsStudioConnected() + }) + ) + + this.dispose.track( + workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ConfigKey.STUDIO_SHARE_EXPERIMENTS_LIVE)) { + return this.sendDataToWebview() + } + }) + ) } public getRoots() { @@ -210,12 +256,6 @@ export class Setup } public async sendDataToWebview() { - if (this.webview?.isVisible && !this.shouldBeShown()) { - this.getWebview()?.dispose() - this.showExperiments() - return - } - const projectInitialized = this.hasRoots() const hasData = this.getHasData() @@ -234,10 +274,12 @@ export class Setup cliCompatible: this.cliCompatible, hasData, isPythonExtensionInstalled: isPythonExtensionInstalled(), + isStudioConnected: this.studioIsConnected, needsGitCommit, needsGitInitialized, projectInitialized, - pythonBinPath: getBinDisplayText(pythonBinPath) + pythonBinPath: getBinDisplayText(pythonBinPath), + shareLiveToStudio: getConfigValue(ConfigKey.STUDIO_SHARE_EXPERIMENTS_LIVE) }) } @@ -282,6 +324,36 @@ export class Setup } } + public removeStudioAccessToken() { + return this.removeSecret(STUDIO_ACCESS_TOKEN_KEY) + } + + public async saveStudioAccessToken() { + const token = await getValidInput( + Title.ENTER_STUDIO_TOKEN, + validateTokenInput, + { password: true } + ) + if (!token) { + return + } + + return this.storeSecret(STUDIO_ACCESS_TOKEN_KEY, token) + } + + public getStudioLiveShareToken() { + return getConfigValue( + ConfigKey.STUDIO_SHARE_EXPERIMENTS_LIVE, + false + ) + ? this.getStudioAccessToken() + : undefined + } + + public getStudioAccessToken() { + return this.studioAccessToken + } + private createWebviewMessageHandler() { const webviewMessages = new WebviewMessages( () => this.getWebview(), @@ -508,4 +580,35 @@ export class Setup workspaceFolderCount: getWorkspaceFolderCount() } } + + private updateIsStudioConnected() { + 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) + } + + private getSecret(key: string) { + const secrets = this.getSecrets() + return secrets.get(key) + } + + private storeSecret(key: string, value: string) { + const secrets = this.getSecrets() + return secrets.store(key, value) + } + + private removeSecret(key: string) { + const secrets = this.getSecrets() + return secrets.delete(key) + } + + private getSecrets() { + return this.secrets + } } diff --git a/extension/src/connect/inputBox.test.ts b/extension/src/setup/inputBox.test.ts similarity index 100% rename from extension/src/connect/inputBox.test.ts rename to extension/src/setup/inputBox.test.ts diff --git a/extension/src/connect/inputBox.ts b/extension/src/setup/inputBox.ts similarity index 100% rename from extension/src/connect/inputBox.ts rename to extension/src/setup/inputBox.ts diff --git a/extension/src/setup/register.ts b/extension/src/setup/register.ts index 04ab7474ff..756e1ebaab 100644 --- a/extension/src/setup/register.ts +++ b/extension/src/setup/register.ts @@ -5,6 +5,26 @@ import { AvailableCommands, InternalCommands } from '../commands/internal' import { RegisteredCliCommands, RegisteredCommands } from '../commands/external' import { getFirstWorkspaceFolder } from '../vscode/workspaceFolders' +const registerSetupStudioCommands = ( + setup: Setup, + internalCommands: InternalCommands +): void => { + internalCommands.registerExternalCommand( + RegisteredCommands.ADD_STUDIO_ACCESS_TOKEN, + () => setup.saveStudioAccessToken() + ) + + internalCommands.registerExternalCommand( + RegisteredCommands.UPDATE_STUDIO_ACCESS_TOKEN, + () => setup.saveStudioAccessToken() + ) + + internalCommands.registerExternalCommand( + RegisteredCommands.REMOVE_STUDIO_ACCESS_TOKEN, + () => setup.removeStudioAccessToken() + ) +} + const registerSetupConfigCommands = ( setup: Setup, internalCommands: InternalCommands @@ -47,4 +67,5 @@ export const registerSetupCommands = ( ) registerSetupConfigCommands(setup, internalCommands) + registerSetupStudioCommands(setup, internalCommands) } diff --git a/extension/src/connect/token.ts b/extension/src/setup/token.ts similarity index 100% rename from extension/src/connect/token.ts rename to extension/src/setup/token.ts diff --git a/extension/src/setup/webview/contract.ts b/extension/src/setup/webview/contract.ts index 0919b06de6..b1e88c7a39 100644 --- a/extension/src/setup/webview/contract.ts +++ b/extension/src/setup/webview/contract.ts @@ -3,18 +3,24 @@ export type SetupData = { cliCompatible: boolean | undefined hasData: boolean | undefined isPythonExtensionInstalled: boolean - needsGitInitialized: boolean | undefined + isStudioConnected: boolean needsGitCommit: boolean + needsGitInitialized: boolean | undefined projectInitialized: boolean pythonBinPath: string | undefined + shareLiveToStudio: boolean } export enum Section { - EXPERIMENTS = 'experiments' + EXPERIMENTS = 'experiments', + STUDIO = 'studio' } export const DEFAULT_SECTION_COLLAPSED = { - [Section.EXPERIMENTS]: false + [Section.EXPERIMENTS]: false, + [Section.STUDIO]: false } export type SectionCollapsed = typeof DEFAULT_SECTION_COLLAPSED + +export const STUDIO_URL = 'https://studio.iterative.ai' diff --git a/extension/src/setup/webview/messages.ts b/extension/src/setup/webview/messages.ts index 2688973751..803612b32e 100644 --- a/extension/src/setup/webview/messages.ts +++ b/extension/src/setup/webview/messages.ts @@ -1,5 +1,5 @@ import { commands } from 'vscode' -import { SetupData as TSetupData } from './contract' +import { STUDIO_URL, SetupData, SetupData as TSetupData } from './contract' import { Logger } from '../../common/logger' import { MessageFromWebview, @@ -14,6 +14,8 @@ import { RegisteredCliCommands, RegisteredCommands } from '../../commands/external' +import { ConfigKey, setConfigValue } from '../../vscode/config' +import { openUrl } from '../../vscode/external' export class WebviewMessages { private readonly getWebview: () => BaseWebview | undefined @@ -28,33 +30,28 @@ export class WebviewMessages { } public sendWebviewMessage({ - cliCompatible, - needsGitInitialized, canGitInitialize, + cliCompatible, + hasData, + isPythonExtensionInstalled, + isStudioConnected, needsGitCommit, + needsGitInitialized, projectInitialized, - isPythonExtensionInstalled, pythonBinPath, - hasData - }: { - cliCompatible: boolean | undefined - needsGitInitialized: boolean | undefined - canGitInitialize: boolean - projectInitialized: boolean - needsGitCommit: boolean - isPythonExtensionInstalled: boolean - pythonBinPath: string | undefined - hasData: boolean | undefined - }) { + shareLiveToStudio + }: SetupData) { void this.getWebview()?.show({ canGitInitialize, cliCompatible, hasData, isPythonExtensionInstalled, + isStudioConnected, needsGitCommit, needsGitInitialized, projectInitialized, - pythonBinPath + pythonBinPath, + shareLiveToStudio }) } @@ -78,6 +75,24 @@ export class WebviewMessages { return commands.executeCommand( RegisteredCommands.EXTENSION_SETUP_WORKSPACE ) + case MessageFromWebviewType.OPEN_STUDIO: + return this.openStudio() + case MessageFromWebviewType.OPEN_STUDIO_PROFILE: + return this.openStudioProfile() + case MessageFromWebviewType.SAVE_STUDIO_TOKEN: + return commands.executeCommand( + RegisteredCommands.ADD_STUDIO_ACCESS_TOKEN + ) + case MessageFromWebviewType.REMOVE_STUDIO_TOKEN: + return commands.executeCommand( + RegisteredCommands.REMOVE_STUDIO_ACCESS_TOKEN + ) + case MessageFromWebviewType.SET_STUDIO_SHARE_EXPERIMENTS_LIVE: + return setConfigValue( + ConfigKey.STUDIO_SHARE_EXPERIMENTS_LIVE, + message.payload + ) + default: Logger.error(`Unexpected message: ${JSON.stringify(message)}`) } @@ -111,4 +126,12 @@ export class WebviewMessages { return autoInstallDvc() } + + private openStudio() { + return openUrl(STUDIO_URL) + } + + private openStudioProfile() { + return openUrl(`${STUDIO_URL}/user/_/profile?section=accessToken`) + } } diff --git a/extension/src/telemetry/constants.ts b/extension/src/telemetry/constants.ts index f751e0e4c2..39f06e32ed 100644 --- a/extension/src/telemetry/constants.ts +++ b/extension/src/telemetry/constants.ts @@ -29,10 +29,6 @@ export const EventName = Object.assign( EXTENSION_EXECUTION_DETAILS_CHANGED: 'extension.executionDetails.changed', EXTENSION_LOAD: 'extension.load', - VIEWS_CONNECT_CLOSED: 'views.connect.closed', - VIEWS_CONNECT_CREATED: 'views.connect.created', - VIEWS_CONNECT_FOCUS_CHANGED: 'views.connect.focusChanged', - VIEWS_EXPERIMENTS_TABLE_CLOSED: 'views.experimentsTable.closed', VIEWS_EXPERIMENTS_TABLE_COLUMNS_REORDERED: 'views.experimentsTable.columnsReordered', @@ -212,10 +208,6 @@ export interface IEventNamePropertyMapping { [EventName.EXTENSION_SHOW_COMMANDS]: undefined [EventName.EXTENSION_SHOW_OUTPUT]: undefined - [EventName.VIEWS_CONNECT_CLOSED]: undefined - [EventName.VIEWS_CONNECT_CREATED]: undefined - [EventName.VIEWS_CONNECT_FOCUS_CHANGED]: undefined - [EventName.VIEWS_EXPERIMENTS_TREE_OPENED]: DvcRootCount [EventName.VIEWS_EXPERIMENTS_FILTER_BY_TREE_OPENED]: DvcRootCount [EventName.VIEWS_EXPERIMENTS_METRICS_AND_PARAMS_TREE_OPENED]: DvcRootCount @@ -290,8 +282,6 @@ export interface IEventNamePropertyMapping { [EventName.SETUP_SHOW]: undefined [EventName.SELECT_FOCUSED_PROJECTS]: undefined - [EventName.CONNECT_SHOW]: undefined - [EventName.OPEN_STUDIO_SETTINGS]: undefined [EventName.ADD_STUDIO_ACCESS_TOKEN]: undefined [EventName.UPDATE_STUDIO_ACCESS_TOKEN]: undefined [EventName.REMOVE_STUDIO_ACCESS_TOKEN]: undefined diff --git a/extension/src/test/suite/connect/index.test.ts b/extension/src/test/suite/connect/index.test.ts deleted file mode 100644 index 4abe912257..0000000000 --- a/extension/src/test/suite/connect/index.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { afterEach, beforeEach, suite, describe, it } from 'mocha' -import { expect } from 'chai' -import { restore, spy, stub } from 'sinon' -import { - EventEmitter, - ExtensionContext, - SecretStorage, - Uri, - WorkspaceConfiguration, - commands, - env, - window, - workspace -} from 'vscode' -import { Disposable } from '../../../extension' -import { - buildResourceLocator, - closeAllEditors, - getMessageReceivedEmitter -} from '../util' -import { Connect } from '../../../connect' -import { MessageFromWebviewType } from '../../../webview/contract' -import { WEBVIEW_TEST_TIMEOUT } from '../timeouts' -import { ContextKey } from '../../../vscode/context' -import { STUDIO_ACCESS_TOKEN_KEY } from '../../../connect/token' -import { RegisteredCommands } from '../../../commands/external' -import { ConfigKey } from '../../../vscode/config' - -suite('Connect Test Suite', () => { - const disposable = Disposable.fn() - - beforeEach(() => { - restore() - }) - - afterEach(() => { - disposable.dispose() - return closeAllEditors() - }) - - const buildConnect = async (mockSecretStorage?: SecretStorage) => { - const resourceLocator = buildResourceLocator(disposable) - const connect = disposable.track( - new Connect( - { - secrets: mockSecretStorage || { - get: stub().resolves(undefined), - onDidChange: stub() - } - } as unknown as ExtensionContext, - resourceLocator.dvcIcon - ) - ) - - const webview = await connect.showWebview() - - const mockMessageReceived = getMessageReceivedEmitter(webview) - - const mockOpenExternal = stub(env, 'openExternal') - const urlOpenedEvent = new Promise(resolve => - mockOpenExternal.callsFake(() => { - resolve(undefined) - return Promise.resolve(true) - }) - ) - - return { connect, mockMessageReceived, mockOpenExternal, urlOpenedEvent } - } - - describe('Connect', () => { - it('should handle a message from the webview to open Studio', async () => { - const { mockMessageReceived, mockOpenExternal, urlOpenedEvent } = - await buildConnect() - - mockMessageReceived.fire({ - type: MessageFromWebviewType.OPEN_STUDIO - }) - - await urlOpenedEvent - expect(mockOpenExternal).to.be.calledWith( - Uri.parse('https://studio.iterative.ai') - ) - }).timeout(WEBVIEW_TEST_TIMEOUT) - - it("should handle a message from the webview to open the user's Studio profile", async () => { - const { mockMessageReceived, mockOpenExternal, urlOpenedEvent } = - await buildConnect() - - mockMessageReceived.fire({ - type: MessageFromWebviewType.OPEN_STUDIO_PROFILE - }) - - await urlOpenedEvent - expect(mockOpenExternal).to.be.calledWith( - Uri.parse( - 'https://studio.iterative.ai/user/_/profile?section=accessToken' - ) - ) - }).timeout(WEBVIEW_TEST_TIMEOUT) - - it("should handle a message from the webview to save the user's Studio access token", async () => { - const executeCommandSpy = spy(commands, 'executeCommand') - const secretsChanged = new EventEmitter<{ key: string }>() - const mockToken = 'isat_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' - - const mockSecrets: { [key: string]: string } = {} - - const onDidChange = secretsChanged.event - const mockGetSecret = (key: string): Promise => - Promise.resolve(mockSecrets[key]) - const mockStoreSecret = (key: string, value: string) => { - mockSecrets[key] = value - secretsChanged.fire({ key }) - return Promise.resolve(undefined) - } - - const mockSecretStorage = { - delete: stub(), - get: mockGetSecret, - onDidChange, - store: mockStoreSecret - } - - const secretsChangedEvent = new Promise(resolve => - onDidChange(() => resolve(undefined)) - ) - - const { connect, mockMessageReceived } = await buildConnect( - mockSecretStorage - ) - await connect.isReady() - expect(executeCommandSpy).to.be.calledWithExactly( - 'setContext', - ContextKey.STUDIO_CONNECTED, - false - ) - - const mockInputBox = stub(window, 'showInputBox').resolves(mockToken) - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - stub(Connect.prototype as any, 'getSecrets').returns(mockSecretStorage) - - mockMessageReceived.fire({ - type: MessageFromWebviewType.SAVE_STUDIO_TOKEN - }) - - await secretsChangedEvent - - expect(mockInputBox).to.be.called - - expect(await mockGetSecret(STUDIO_ACCESS_TOKEN_KEY)).to.equal(mockToken) - - expect(executeCommandSpy).to.be.calledWithExactly( - 'setContext', - ContextKey.STUDIO_CONNECTED, - true - ) - }).timeout(WEBVIEW_TEST_TIMEOUT) - - it('should handle a message to set dvc.studio.shareExperimentsLive', async () => { - const { connect, mockMessageReceived } = await buildConnect() - await connect.isReady() - - const mockUpdate = stub() - - stub(workspace, 'getConfiguration').returns({ - update: mockUpdate - } as unknown as WorkspaceConfiguration) - - mockMessageReceived.fire({ - payload: false, - type: MessageFromWebviewType.SET_STUDIO_SHARE_EXPERIMENTS_LIVE - }) - - expect(mockUpdate).to.be.calledWithExactly( - ConfigKey.STUDIO_SHARE_EXPERIMENTS_LIVE, - false - ) - }) - - it('should be able to delete the Studio access token from secrets storage', async () => { - const mockDelete = stub() - stub( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - Connect.prototype as any, - 'getSecrets' - ).returns({ delete: mockDelete }) - - await commands.executeCommand( - RegisteredCommands.REMOVE_STUDIO_ACCESS_TOKEN - ) - - expect(mockDelete).to.be.calledWithExactly(STUDIO_ACCESS_TOKEN_KEY) - }) - }) -}) diff --git a/extension/src/test/suite/experiments/index.test.ts b/extension/src/test/suite/experiments/index.test.ts index 08370d2427..54c11a00bb 100644 --- a/extension/src/test/suite/experiments/index.test.ts +++ b/extension/src/test/suite/experiments/index.test.ts @@ -79,7 +79,6 @@ import { Setup } from '../../../setup' import * as FileSystem from '../../../fileSystem' import * as ProcessExecution from '../../../process/execution' import { DvcReader } from '../../../cli/dvc/reader' -import { Connect } from '../../../connect' import { DvcViewer } from '../../../cli/dvc/viewer' import { DEFAULT_NB_ITEMS_PER_ROW } from '../../../plots/webview/contract' @@ -601,7 +600,7 @@ suite('Experiments Test Suite', () => { const executeCommandSpy = spy(commands, 'executeCommand') const mockGetStudioAccessToken = stub( - Connect.prototype, + Setup.prototype, 'getStudioAccessToken' ) @@ -620,7 +619,7 @@ suite('Experiments Test Suite', () => { await tokenAccessed expect(executeCommandSpy).to.be.calledWithExactly( - RegisteredCommands.CONNECT_SHOW + RegisteredCommands.SETUP_SHOW ) }).timeout(WEBVIEW_TEST_TIMEOUT) diff --git a/extension/src/test/suite/patch.test.ts b/extension/src/test/suite/patch.test.ts index cf6fda29f0..d54ac9ee0c 100644 --- a/extension/src/test/suite/patch.test.ts +++ b/extension/src/test/suite/patch.test.ts @@ -122,7 +122,7 @@ suite('Patch Test Suite', () => { expect(mockFetch).to.be.calledOnce expect(mockErrorWithOptions).to.be.calledOnce expect(executeCommandSpy).to.be.calledWithExactly( - RegisteredCommands.CONNECT_SHOW + RegisteredCommands.SETUP_SHOW ) const { metrics, params, name } = expShowFixture[ diff --git a/extension/src/test/suite/setup/index.test.ts b/extension/src/test/suite/setup/index.test.ts index 688e6f9bf0..3c38a23224 100644 --- a/extension/src/test/suite/setup/index.test.ts +++ b/extension/src/test/suite/setup/index.test.ts @@ -3,7 +3,15 @@ import { afterEach, beforeEach, describe, it, suite } from 'mocha' import { ensureFileSync, remove } from 'fs-extra' import { expect } from 'chai' import { SinonStub, restore, spy, stub } from 'sinon' -import { QuickPickItem, Uri, commands, window, workspace } from 'vscode' +import { + EventEmitter, + QuickPickItem, + Uri, + WorkspaceConfiguration, + commands, + window, + workspace +} from 'vscode' import { buildSetup, buildSetupWithWatchers, TEMP_DIR } from './util' import { closeAllEditors, @@ -35,6 +43,9 @@ import { StopWatch } from '../../../util/time' import { MIN_CLI_VERSION } from '../../../cli/dvc/contract' import { run } from '../../../setup/runner' import * as Python from '../../../extensions/python' +import { STUDIO_ACCESS_TOKEN_KEY } from '../../../setup/token' +import { ContextKey } from '../../../vscode/context' +import { Setup } from '../../../setup' suite('Setup Test Suite', () => { const disposable = Disposable.fn() @@ -198,22 +209,6 @@ suite('Setup Test Suite', () => { ) }).timeout(WEBVIEW_TEST_TIMEOUT) - it('should close the webview and open the experiments when the setup is done', async () => { - const { setup, mockOpenExperiments } = buildSetup(disposable, true) - - const closeWebviewSpy = spy(BaseWebview.prototype, 'dispose') - - const webview = await setup.showWebview() - await webview.isReady() - - stub(setup, 'hasRoots').returns(true) - setup.setCliCompatible(true) - setup.setAvailable(true) - - expect(closeWebviewSpy).to.be.calledOnce - expect(mockOpenExperiments).to.be.calledOnce - }).timeout(WEBVIEW_TEST_TIMEOUT) - it('should send the expected message to the webview when there is no CLI available', async () => { const { config, setup, messageSpy } = buildSetup(disposable) @@ -244,10 +239,12 @@ suite('Setup Test Suite', () => { cliCompatible: undefined, hasData: false, isPythonExtensionInstalled: false, + isStudioConnected: false, needsGitCommit: true, needsGitInitialized: true, projectInitialized: false, - pythonBinPath: undefined + pythonBinPath: undefined, + shareLiveToStudio: false }) }).timeout(WEBVIEW_TEST_TIMEOUT) @@ -281,10 +278,12 @@ suite('Setup Test Suite', () => { cliCompatible: true, hasData: false, isPythonExtensionInstalled: false, + isStudioConnected: false, needsGitCommit: true, needsGitInitialized: true, projectInitialized: false, - pythonBinPath: undefined + pythonBinPath: undefined, + shareLiveToStudio: false }) }).timeout(WEBVIEW_TEST_TIMEOUT) @@ -324,10 +323,12 @@ suite('Setup Test Suite', () => { cliCompatible: true, hasData: false, isPythonExtensionInstalled: false, + isStudioConnected: false, needsGitCommit: false, needsGitInitialized: false, projectInitialized: false, - pythonBinPath: undefined + pythonBinPath: undefined, + shareLiveToStudio: false }) }).timeout(WEBVIEW_TEST_TIMEOUT) @@ -367,10 +368,12 @@ suite('Setup Test Suite', () => { cliCompatible: true, hasData: false, isPythonExtensionInstalled: false, + isStudioConnected: false, needsGitCommit: true, needsGitInitialized: false, projectInitialized: true, - pythonBinPath: undefined + pythonBinPath: undefined, + shareLiveToStudio: false }) }).timeout(WEBVIEW_TEST_TIMEOUT) @@ -633,5 +636,154 @@ suite('Setup Test Suite', () => { 'should set dvc.cli.incompatible to false if the version is compatible' ).to.be.calledWithExactly('setContext', 'dvc.cli.incompatible', false) }) + + it('should handle a message from the webview to open Studio', async () => { + const { mockOpenExternal, setup, urlOpenedEvent } = buildSetup(disposable) + + const webview = await setup.showWebview() + await webview.isReady() + + const mockMessageReceived = getMessageReceivedEmitter(webview) + + mockMessageReceived.fire({ + type: MessageFromWebviewType.OPEN_STUDIO + }) + + await urlOpenedEvent + expect(mockOpenExternal).to.be.calledWith( + Uri.parse('https://studio.iterative.ai') + ) + }).timeout(WEBVIEW_TEST_TIMEOUT) + + it("should handle a message from the webview to open the user's Studio profile", async () => { + const { setup, mockOpenExternal, urlOpenedEvent } = buildSetup(disposable) + + const webview = await setup.showWebview() + await webview.isReady() + + const mockMessageReceived = getMessageReceivedEmitter(webview) + + mockMessageReceived.fire({ + type: MessageFromWebviewType.OPEN_STUDIO_PROFILE + }) + + await urlOpenedEvent + expect(mockOpenExternal).to.be.calledWith( + Uri.parse( + 'https://studio.iterative.ai/user/_/profile?section=accessToken' + ) + ) + }).timeout(WEBVIEW_TEST_TIMEOUT) + + it("should handle a message from the webview to save the user's Studio access token", async () => { + const secretsChanged = disposable.track( + new EventEmitter<{ key: string }>() + ) + const mockToken = 'isat_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + + const mockSecrets: { [key: string]: string } = {} + + const onDidChange = secretsChanged.event + const mockGetSecret = (key: string): Promise => + Promise.resolve(mockSecrets[key]) + const mockStoreSecret = (key: string, value: string) => { + mockSecrets[key] = value + secretsChanged.fire({ key }) + return Promise.resolve(undefined) + } + + const mockSecretStorage = { + delete: stub(), + get: mockGetSecret, + onDidChange, + store: mockStoreSecret + } + + const secretsChangedEvent = new Promise(resolve => + onDidChange(() => resolve(undefined)) + ) + + const { setup, mockExecuteCommand } = buildSetup( + disposable, + false, + false, + false, + false, + mockSecretStorage + ) + mockExecuteCommand.restore() + + const executeCommandSpy = spy(commands, 'executeCommand') + const webview = await setup.showWebview() + await webview.isReady() + + const mockMessageReceived = getMessageReceivedEmitter(webview) + + expect(executeCommandSpy).to.be.calledWithExactly( + 'setContext', + ContextKey.STUDIO_CONNECTED, + false + ) + + const mockInputBox = stub(window, 'showInputBox').resolves(mockToken) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + stub(Setup.prototype as any, 'getSecrets').returns(mockSecretStorage) + + mockMessageReceived.fire({ + type: MessageFromWebviewType.SAVE_STUDIO_TOKEN + }) + + await secretsChangedEvent + + expect(mockInputBox).to.be.called + + expect(await mockGetSecret(STUDIO_ACCESS_TOKEN_KEY)).to.equal(mockToken) + + expect(executeCommandSpy).to.be.calledWithExactly( + 'setContext', + ContextKey.STUDIO_CONNECTED, + true + ) + }).timeout(WEBVIEW_TEST_TIMEOUT) + + it('should handle a message to set dvc.studio.shareExperimentsLive', async () => { + const { setup } = buildSetup(disposable) + const webview = await setup.showWebview() + await webview.isReady() + + const mockMessageReceived = getMessageReceivedEmitter(webview) + + const mockUpdate = stub() + + stub(workspace, 'getConfiguration').returns({ + update: mockUpdate + } as unknown as WorkspaceConfiguration) + + mockMessageReceived.fire({ + payload: false, + type: MessageFromWebviewType.SET_STUDIO_SHARE_EXPERIMENTS_LIVE + }) + + expect(mockUpdate).to.be.calledWithExactly( + Config.ConfigKey.STUDIO_SHARE_EXPERIMENTS_LIVE, + false + ) + }) + + it('should be able to delete the Studio access token from secrets storage', async () => { + const mockDelete = stub() + stub( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Setup.prototype as any, + 'getSecrets' + ).returns({ delete: mockDelete }) + + await commands.executeCommand( + RegisteredCommands.REMOVE_STUDIO_ACCESS_TOKEN + ) + + expect(mockDelete).to.be.calledWithExactly(STUDIO_ACCESS_TOKEN_KEY) + }) }) }) diff --git a/extension/src/test/suite/setup/util.ts b/extension/src/test/suite/setup/util.ts index 878b901aff..ae95867b86 100644 --- a/extension/src/test/suite/setup/util.ts +++ b/extension/src/test/suite/setup/util.ts @@ -1,5 +1,11 @@ import { join } from 'path' -import { EventEmitter, commands } from 'vscode' +import { + EventEmitter, + ExtensionContext, + SecretStorage, + commands, + env +} from 'vscode' import { Disposer } from '@hediet/std/disposable' import { fake, stub } from 'sinon' import { ensureDirSync } from 'fs-extra' @@ -26,7 +32,8 @@ export const buildSetup = ( hasData = false, noDvcRoot = true, noGitRoot = true, - noGitCommits = true + noGitCommits = true, + mockSecretStorage: SecretStorage | undefined = undefined ) => { const { config, @@ -70,8 +77,22 @@ export const buildSetup = ( undefined ) + const mockOpenExternal = stub(env, 'openExternal') + const urlOpenedEvent = new Promise(resolve => + mockOpenExternal.callsFake(() => { + resolve(undefined) + return Promise.resolve(true) + }) + ) + const setup = disposer.track( new Setup( + { + secrets: mockSecretStorage || { + get: stub().resolves(undefined), + onDidChange: stub() + } + } as unknown as ExtensionContext, config, internalCommands, { @@ -98,10 +119,12 @@ export const buildSetup = ( mockGlobalVersion, mockInitializeGit, mockOpenExperiments, + mockOpenExternal, mockRunSetup, mockVersion, resourceLocator, - setup + setup, + urlOpenedEvent } } @@ -123,6 +146,12 @@ export const buildSetupWithWatchers = async (disposer: Disposer) => { const setup = disposer.track( new Setup( + { + secrets: { + get: stub().resolves(undefined), + onDidChange: stub() + } + } as unknown as ExtensionContext, config, mockInternalCommands, { diff --git a/extension/src/test/suite/util.ts b/extension/src/test/suite/util.ts index 4def77c306..b4d01280ff 100644 --- a/extension/src/test/suite/util.ts +++ b/extension/src/test/suite/util.ts @@ -35,7 +35,6 @@ import { DvcExecutor } from '../../cli/dvc/executor' import { GitReader } from '../../cli/git/reader' import { SetupData } from '../../setup/webview/contract' import { DvcViewer } from '../../cli/dvc/viewer' -import { ConnectData } from '../../connect/webview/contract' import { Toast } from '../../vscode/toast' import { GitExecutor } from '../../cli/git/executor' @@ -258,7 +257,7 @@ export const buildDependencies = ( } export const getMessageReceivedEmitter = ( - webview: BaseWebview + webview: BaseWebview ): EventEmitter => (webview as any).messageReceived export const getInputBoxEvent = (mockInputValue: string) => { diff --git a/extension/src/vscode/context.ts b/extension/src/vscode/context.ts index 535413c48e..04ed2f6c7e 100644 --- a/extension/src/vscode/context.ts +++ b/extension/src/vscode/context.ts @@ -3,7 +3,6 @@ import { commands } from 'vscode' export enum ContextKey { CLI_INCOMPATIBLE = 'dvc.cli.incompatible', COMMANDS_AVAILABLE = 'dvc.commands.available', - CONNECT_WEBVIEW_ACTIVE = 'dvc.connect.webview.active', EXPERIMENT_CHECKPOINTS = 'dvc.experiment.checkpoints', EXPERIMENTS_WEBVIEW_ACTIVE = 'dvc.experiments.webview.active', EXPERIMENT_RUNNING = 'dvc.experiment.running', diff --git a/extension/src/webview/constants.ts b/extension/src/webview/constants.ts index 95526a1395..762e1c81b2 100644 --- a/extension/src/webview/constants.ts +++ b/extension/src/webview/constants.ts @@ -1,16 +1,8 @@ -import { - distPath, - connect, - react, - experiments, - plots, - setup -} from 'dvc-vscode-webview' +import { distPath, react, experiments, plots, setup } from 'dvc-vscode-webview' import { EventName, IEventNamePropertyMapping } from '../telemetry/constants' import { ContextKey } from '../vscode/context' export enum ViewKey { - CONNECT = 'dvc-connect', EXPERIMENTS = 'dvc-experiments', PLOTS = 'dvc-plots', SETUP = 'dvc-setup' @@ -33,18 +25,6 @@ export const WebviewDetails: { viewKey: ViewKey } } = { - [ViewKey.CONNECT]: { - contextKey: ContextKey.CONNECT_WEBVIEW_ACTIVE, - distPath, - eventNames: { - closedEvent: EventName.VIEWS_CONNECT_CLOSED, - createdEvent: EventName.VIEWS_CONNECT_CREATED, - focusChangedEvent: EventName.VIEWS_CONNECT_FOCUS_CHANGED - }, - scripts: [react, connect], - title: 'Connect', - viewKey: ViewKey.CONNECT - }, [ViewKey.EXPERIMENTS]: { contextKey: ContextKey.EXPERIMENTS_WEBVIEW_ACTIVE, distPath, diff --git a/extension/src/webview/contract.ts b/extension/src/webview/contract.ts index 4cd05b8ba6..965b8291b2 100644 --- a/extension/src/webview/contract.ts +++ b/extension/src/webview/contract.ts @@ -1,4 +1,3 @@ -import { ConnectData } from '../connect/webview/contract' import { SortDefinition } from '../experiments/model/sortBy' import { TableData } from '../experiments/webview/contract' import { @@ -10,7 +9,7 @@ import { } from '../plots/webview/contract' import { SetupData } from '../setup/webview/contract' -export type WebviewData = TableData | PlotsData | SetupData | ConnectData +export type WebviewData = TableData | PlotsData | SetupData export enum MessageFromWebviewType { INITIALIZED = 'initialized', diff --git a/webview/index.d.ts b/webview/index.d.ts index f056f41cba..15d44552d4 100644 --- a/webview/index.d.ts +++ b/webview/index.d.ts @@ -1,4 +1,3 @@ -export const connect: string export const distPath: string export const experiments: string export const plots: string diff --git a/webview/index.js b/webview/index.js index f45978b6fb..bfb661e5ab 100644 --- a/webview/index.js +++ b/webview/index.js @@ -6,7 +6,6 @@ const path = require('path') module.exports.distPath = path.join(__dirname, 'dist') -module.exports.connect = path.join(__dirname, 'dist/connect.js') module.exports.experiments = path.join(__dirname, 'dist/experiments.js') module.exports.plots = path.join(__dirname, 'dist/plots.js') module.exports.setup = path.join(__dirname, 'dist/setup.js') diff --git a/webview/src/connect/components/App.test.tsx b/webview/src/connect/components/App.test.tsx deleted file mode 100644 index b86924891c..0000000000 --- a/webview/src/connect/components/App.test.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import React from 'react' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { - MessageFromWebviewType, - MessageToWebviewType -} from 'dvc/src/webview/contract' -import { ConnectData } from 'dvc/src/connect/webview/contract' -import { App } from './App' -import { vsCodeApi } from '../../shared/api' - -jest.mock('../../shared/api') -jest.mock('../../util/styles') - -const { postMessage } = vsCodeApi -const mockPostMessage = jest.mocked(postMessage) - -beforeEach(() => { - jest.clearAllMocks() -}) - -afterEach(() => { - cleanup() -}) - -const setData = ({ isStudioConnected, shareLiveToStudio }: ConnectData) => { - fireEvent( - window, - new MessageEvent('message', { - data: { - data: { - isStudioConnected, - shareLiveToStudio - }, - type: MessageToWebviewType.SET_DATA - } - }) - ) -} - -const renderApp = (isStudioConnected = false, shareLiveToStudio = false) => { - render() - setData({ isStudioConnected, shareLiveToStudio }) -} - -describe('App', () => { - describe('Studio not connected', () => { - it('should show three buttons which walk the user through saving a token', async () => { - renderApp() - const buttons = await screen.findAllByRole('button') - expect(buttons).toHaveLength(3) - }) - - it('should show a button which opens Studio', () => { - renderApp() - mockPostMessage.mockClear() - const button = screen.getByText('Sign In') - fireEvent.click(button) - expect(mockPostMessage).toHaveBeenCalledTimes(1) - expect(mockPostMessage).toHaveBeenCalledWith({ - type: MessageFromWebviewType.OPEN_STUDIO - }) - }) - - it("should show a button which opens the user's Studio profile", () => { - renderApp() - mockPostMessage.mockClear() - const button = screen.getByText('Get Token') - fireEvent.click(button) - expect(mockPostMessage).toHaveBeenCalledTimes(1) - expect(mockPostMessage).toHaveBeenCalledWith({ - type: MessageFromWebviewType.OPEN_STUDIO_PROFILE - }) - }) - - it("should show a button which allows the user's to save their Studio access token", () => { - renderApp() - mockPostMessage.mockClear() - const button = screen.getByText('Save') - fireEvent.click(button) - expect(mockPostMessage).toHaveBeenCalledTimes(1) - expect(mockPostMessage).toHaveBeenCalledWith({ - type: MessageFromWebviewType.SAVE_STUDIO_TOKEN - }) - }) - }) - - describe('Studio connected', () => { - it('should render a checkbox which can be used to update dvc.studio.shareExperimentsLive', () => { - const shareExperimentsLive = false - renderApp(true, shareExperimentsLive) - mockPostMessage.mockClear() - const checkbox = screen.getByRole('checkbox') - fireEvent.click(checkbox) - expect(mockPostMessage).toHaveBeenCalledWith({ - payload: !shareExperimentsLive, - type: MessageFromWebviewType.SET_STUDIO_SHARE_EXPERIMENTS_LIVE - }) - }) - - it('should enable the user to update their studio token', () => { - const shareExperimentsLive = false - renderApp(true, shareExperimentsLive) - mockPostMessage.mockClear() - const button = screen.getByText('Update Token') - fireEvent.click(button) - expect(mockPostMessage).toHaveBeenCalledWith({ - type: MessageFromWebviewType.SAVE_STUDIO_TOKEN - }) - }) - }) -}) diff --git a/webview/src/connect/components/App.tsx b/webview/src/connect/components/App.tsx deleted file mode 100644 index 29717f20af..0000000000 --- a/webview/src/connect/components/App.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React, { useCallback, useState } from 'react' -import { - MessageFromWebviewType, - MessageToWebview -} from 'dvc/src/webview/contract' -import { ConnectData } from 'dvc/src/connect/webview/contract' -import { Studio } from './Studio' -import { useVsCodeMessaging } from '../../shared/hooks/useVsCodeMessaging' -import { sendMessage } from '../../shared/vscode' - -export const App: React.FC = () => { - const [isStudioConnected, setIsStudioConnected] = useState(false) - const [shareLiveToStudio, setShareLiveToStudioValue] = - useState(false) - useVsCodeMessaging( - useCallback( - ({ data }: { data: MessageToWebview }) => { - setIsStudioConnected(data.data.isStudioConnected) - setShareLiveToStudioValue(data.data.shareLiveToStudio) - }, - [setIsStudioConnected, setShareLiveToStudioValue] - ) - ) - - const setShareLiveToStudio = (shouldShareLive: boolean) => { - setShareLiveToStudioValue(shouldShareLive) - sendMessage({ - payload: shouldShareLive, - type: MessageFromWebviewType.SET_STUDIO_SHARE_EXPERIMENTS_LIVE - }) - } - - return ( - - ) -} diff --git a/webview/src/connect/components/messages.ts b/webview/src/connect/components/messages.ts deleted file mode 100644 index 4639e05766..0000000000 --- a/webview/src/connect/components/messages.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { MessageFromWebviewType } from 'dvc/src/webview/contract' -import { sendMessage } from '../../shared/vscode' - -export const openStudio = () => - sendMessage({ type: MessageFromWebviewType.OPEN_STUDIO }) - -export const openStudioProfile = () => - sendMessage({ type: MessageFromWebviewType.OPEN_STUDIO_PROFILE }) - -export const saveStudioToken = () => - sendMessage({ type: MessageFromWebviewType.SAVE_STUDIO_TOKEN }) - -export const removeStudioToken = () => - sendMessage({ type: MessageFromWebviewType.REMOVE_STUDIO_TOKEN }) diff --git a/webview/src/connect/index.tsx b/webview/src/connect/index.tsx deleted file mode 100644 index 95de9279c6..0000000000 --- a/webview/src/connect/index.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import '../shared/style.scss' -import { App } from './components/App' - -const root = ReactDOM.createRoot(document.querySelector('#root') as HTMLElement) -root.render() diff --git a/webview/src/setup/components/App.test.tsx b/webview/src/setup/components/App.test.tsx index 63f04c5149..4f9a4b2d75 100644 --- a/webview/src/setup/components/App.test.tsx +++ b/webview/src/setup/components/App.test.tsx @@ -5,6 +5,7 @@ import { } from 'dvc/src/webview/contract' import '@testing-library/jest-dom/extend-expect' import React from 'react' +import { SetupData } from 'dvc/src/setup/webview/contract' import { App } from './App' import { vsCodeApi } from '../../shared/api' @@ -14,23 +15,18 @@ jest.mock('../../shared/components/codeSlider/CodeSlider') const { postMessage } = vsCodeApi const mockPostMessage = jest.mocked(postMessage) -const setData = ({ +const renderApp = ({ canGitInitialize, cliCompatible, hasData, isPythonExtensionInstalled, + isStudioConnected, needsGitInitialized, projectInitialized, - pythonBinPath -}: { - canGitInitialize: boolean | undefined - cliCompatible: boolean | undefined - hasData: boolean | undefined - isPythonExtensionInstalled: boolean - needsGitInitialized: boolean | undefined - projectInitialized: boolean - pythonBinPath: string | undefined -}) => { + pythonBinPath, + shareLiveToStudio +}: SetupData) => { + render() fireEvent( window, new MessageEvent('message', { @@ -40,9 +36,11 @@ const setData = ({ cliCompatible, hasData, isPythonExtensionInstalled, + isStudioConnected, needsGitInitialized, projectInitialized, - pythonBinPath + pythonBinPath, + shareLiveToStudio }, type: MessageToWebviewType.SET_DATA } @@ -51,298 +49,472 @@ const setData = ({ } describe('App', () => { - it('should send the initialized message on first render', () => { - render() - expect(mockPostMessage).toHaveBeenCalledWith({ - type: MessageFromWebviewType.INITIALIZED + describe('Experiments', () => { + it('should send the initialized message on first render', () => { + render() + expect(mockPostMessage).toHaveBeenCalledWith({ + type: MessageFromWebviewType.INITIALIZED + }) + expect(mockPostMessage).toHaveBeenCalledTimes(1) }) - expect(mockPostMessage).toHaveBeenCalledTimes(1) - }) - it('should show a screen saying that DVC is incompatible if the cli version is unexpected', () => { - render() - setData({ - canGitInitialize: undefined, - cliCompatible: false, - hasData: false, - isPythonExtensionInstalled: false, - needsGitInitialized: undefined, - projectInitialized: false, - pythonBinPath: undefined + it('should show a screen saying that DVC is incompatible if the cli version is unexpected', () => { + renderApp({ + canGitInitialize: false, + cliCompatible: false, + hasData: false, + isPythonExtensionInstalled: false, + isStudioConnected: false, + needsGitCommit: false, + needsGitInitialized: undefined, + projectInitialized: false, + pythonBinPath: undefined, + shareLiveToStudio: false + }) + + expect(screen.getByText('DVC is incompatible')).toBeInTheDocument() + + const button = screen.getByText('Check Compatibility') + expect(button).toBeInTheDocument() + + fireEvent.click(button) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: MessageFromWebviewType.CHECK_CLI_COMPATIBLE + }) }) - expect(screen.getByText('DVC is incompatible')).toBeInTheDocument() - - const button = screen.getByText('Check Compatibility') - expect(button).toBeInTheDocument() - - fireEvent.click(button) - expect(mockPostMessage).toHaveBeenCalledWith({ - type: MessageFromWebviewType.CHECK_CLI_COMPATIBLE + it('should show a screen saying that DVC is not installed if the cli is unavailable', () => { + renderApp({ + canGitInitialize: false, + cliCompatible: undefined, + hasData: false, + isPythonExtensionInstalled: false, + isStudioConnected: false, + needsGitCommit: false, + needsGitInitialized: undefined, + projectInitialized: false, + pythonBinPath: undefined, + shareLiveToStudio: false + }) + + expect( + screen.getByText('DVC is currently unavailable') + ).toBeInTheDocument() }) - }) - it('should show a screen saying that DVC is not installed if the cli is unavailable', () => { - render() - setData({ - canGitInitialize: undefined, - cliCompatible: undefined, - hasData: false, - isPythonExtensionInstalled: false, - needsGitInitialized: undefined, - projectInitialized: false, - pythonBinPath: undefined + it('should tell the user they cannot install DVC without a Python interpreter', () => { + renderApp({ + canGitInitialize: false, + cliCompatible: undefined, + hasData: false, + isPythonExtensionInstalled: false, + isStudioConnected: false, + needsGitCommit: false, + needsGitInitialized: undefined, + projectInitialized: false, + pythonBinPath: undefined, + shareLiveToStudio: false + }) + + expect( + screen.getByText( + 'DVC & DVCLive cannot be auto-installed as Python was not located.' + ) + ).toBeInTheDocument() + expect(screen.queryByText('Install')).not.toBeInTheDocument() }) - expect(screen.getByText('DVC is currently unavailable')).toBeInTheDocument() - }) - - it('should tell the user they cannot install DVC without a Python interpreter', () => { - render() - setData({ - canGitInitialize: undefined, - cliCompatible: undefined, - hasData: false, - isPythonExtensionInstalled: false, - needsGitInitialized: undefined, - projectInitialized: false, - pythonBinPath: undefined + it('should tell the user they can auto-install DVC with a Python interpreter', () => { + const defaultInterpreter = 'python' + renderApp({ + canGitInitialize: false, + cliCompatible: undefined, + hasData: false, + isPythonExtensionInstalled: false, + isStudioConnected: false, + needsGitCommit: false, + needsGitInitialized: undefined, + projectInitialized: false, + pythonBinPath: defaultInterpreter, + shareLiveToStudio: false + }) + + expect( + screen.getByText( + `DVC & DVCLive can be auto-installed as packages with ${defaultInterpreter}` + ) + ).toBeInTheDocument() + expect(screen.getByText('Install')).toBeInTheDocument() }) - expect( - screen.getByText( - 'DVC & DVCLive cannot be auto-installed as Python was not located.' - ) - ).toBeInTheDocument() - expect(screen.queryByText('Install')).not.toBeInTheDocument() - }) - - it('should tell the user they can auto-install DVC with a Python interpreter', () => { - render() - const defaultInterpreter = 'python' - setData({ - canGitInitialize: undefined, - cliCompatible: undefined, - hasData: false, - isPythonExtensionInstalled: false, - needsGitInitialized: undefined, - projectInitialized: false, - pythonBinPath: defaultInterpreter + it('should let the user find another Python interpreter to install DVC when the Python extension is not installed', () => { + renderApp({ + canGitInitialize: false, + cliCompatible: undefined, + hasData: false, + isPythonExtensionInstalled: false, + isStudioConnected: false, + needsGitCommit: false, + needsGitInitialized: undefined, + projectInitialized: false, + pythonBinPath: 'python', + shareLiveToStudio: false + }) + + const button = screen.getByText('Setup The Workspace') + fireEvent.click(button) + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: MessageFromWebviewType.SETUP_WORKSPACE + }) }) - expect( - screen.getByText( - `DVC & DVCLive can be auto-installed as packages with ${defaultInterpreter}` - ) - ).toBeInTheDocument() - expect(screen.getByText('Install')).toBeInTheDocument() - }) - - it('should let the user find another Python interpreter to install DVC when the Python extension is not installed', () => { - render() - setData({ - canGitInitialize: undefined, - cliCompatible: undefined, - hasData: false, - isPythonExtensionInstalled: false, - needsGitInitialized: undefined, - projectInitialized: false, - pythonBinPath: 'python' + it('should let the user find another Python interpreter to install DVC when the Python extension is installed', () => { + renderApp({ + canGitInitialize: false, + cliCompatible: undefined, + hasData: false, + isPythonExtensionInstalled: true, + isStudioConnected: false, + needsGitCommit: false, + needsGitInitialized: undefined, + projectInitialized: false, + pythonBinPath: 'python', + shareLiveToStudio: false + }) + + const button = screen.getByText('Select Python Interpreter') + fireEvent.click(button) + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: MessageFromWebviewType.SELECT_PYTHON_INTERPRETER + }) }) - const button = screen.getByText('Setup The Workspace') - fireEvent.click(button) - - expect(mockPostMessage).toHaveBeenCalledWith({ - type: MessageFromWebviewType.SETUP_WORKSPACE + it('should let the user auto-install DVC under the right conditions', () => { + renderApp({ + canGitInitialize: false, + cliCompatible: undefined, + hasData: false, + isPythonExtensionInstalled: true, + isStudioConnected: false, + needsGitCommit: false, + needsGitInitialized: undefined, + projectInitialized: false, + pythonBinPath: 'python', + shareLiveToStudio: false + }) + + const button = screen.getByText('Install') + fireEvent.click(button) + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: MessageFromWebviewType.INSTALL_DVC + }) }) - }) - it('should let the user find another Python interpreter to install DVC when the Python extension is installed', () => { - render() - setData({ - canGitInitialize: undefined, - cliCompatible: undefined, - hasData: false, - isPythonExtensionInstalled: true, - needsGitInitialized: undefined, - projectInitialized: false, - pythonBinPath: 'python' + it('should not show a screen saying that DVC is not installed if the cli is available', () => { + renderApp({ + canGitInitialize: false, + cliCompatible: true, + hasData: false, + isPythonExtensionInstalled: false, + isStudioConnected: false, + needsGitCommit: false, + needsGitInitialized: undefined, + projectInitialized: false, + pythonBinPath: undefined, + shareLiveToStudio: false + }) + + expect( + screen.queryByText('DVC is currently unavailable') + ).not.toBeInTheDocument() }) - const button = screen.getByText('Select Python Interpreter') - fireEvent.click(button) - - expect(mockPostMessage).toHaveBeenCalledWith({ - type: MessageFromWebviewType.SELECT_PYTHON_INTERPRETER + it('should not show a screen saying that DVC is not initialized if the project is not initialized and git is uninitialized', () => { + renderApp({ + canGitInitialize: false, + cliCompatible: true, + hasData: false, + isPythonExtensionInstalled: false, + isStudioConnected: false, + needsGitCommit: false, + needsGitInitialized: true, + projectInitialized: false, + pythonBinPath: undefined, + shareLiveToStudio: false + }) + + expect(screen.getByText('DVC is not initialized')).toBeInTheDocument() }) - }) - it('should let the user auto-install DVC under the right conditions', () => { - render() - setData({ - canGitInitialize: undefined, - cliCompatible: undefined, - hasData: false, - isPythonExtensionInstalled: true, - needsGitInitialized: undefined, - projectInitialized: false, - pythonBinPath: 'python' + it('should offer to initialize Git if it is possible', () => { + renderApp({ + canGitInitialize: true, + cliCompatible: true, + hasData: false, + isPythonExtensionInstalled: false, + isStudioConnected: false, + needsGitCommit: false, + needsGitInitialized: true, + projectInitialized: false, + pythonBinPath: undefined, + shareLiveToStudio: false + }) + + const initializeButton = screen.getByText('Initialize Git') + expect(initializeButton).toBeInTheDocument() + fireEvent.click(initializeButton) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: MessageFromWebviewType.INITIALIZE_GIT + }) + + renderApp({ + canGitInitialize: false, + cliCompatible: true, + hasData: false, + isPythonExtensionInstalled: false, + isStudioConnected: false, + needsGitCommit: false, + needsGitInitialized: true, + projectInitialized: false, + pythonBinPath: undefined, + shareLiveToStudio: false + }) + + expect(screen.queryByText('Initialize Git')).not.toBeInTheDocument() }) - const button = screen.getByText('Install') - fireEvent.click(button) - - expect(mockPostMessage).toHaveBeenCalledWith({ - type: MessageFromWebviewType.INSTALL_DVC + it('should show a screen saying that DVC is not initialized if the project is not initialized and dvc is installed', () => { + renderApp({ + canGitInitialize: false, + cliCompatible: true, + hasData: false, + isPythonExtensionInstalled: false, + isStudioConnected: false, + needsGitCommit: false, + needsGitInitialized: undefined, + projectInitialized: false, + pythonBinPath: undefined, + shareLiveToStudio: false + }) + + expect(screen.getByText('DVC is not initialized')).toBeInTheDocument() }) - }) - it('should not show a screen saying that DVC is not installed if the cli is available', () => { - render() - setData({ - canGitInitialize: undefined, - cliCompatible: true, - hasData: false, - isPythonExtensionInstalled: false, - needsGitInitialized: undefined, - projectInitialized: false, - pythonBinPath: undefined + it('should not show a screen saying that DVC is not initialized if the project is initialized and dvc is installed', () => { + renderApp({ + canGitInitialize: false, + cliCompatible: true, + hasData: false, + isPythonExtensionInstalled: false, + isStudioConnected: false, + needsGitCommit: false, + needsGitInitialized: undefined, + projectInitialized: true, + pythonBinPath: undefined, + shareLiveToStudio: false + }) + + expect( + screen.queryByText('DVC is not initialized') + ).not.toBeInTheDocument() }) - expect( - screen.queryByText('DVC is currently unavailable') - ).not.toBeInTheDocument() - }) - - it('should not show a screen saying that DVC is not initialized if the project is not initialized and git is uninitialized', () => { - render() - setData({ - canGitInitialize: undefined, - cliCompatible: true, - hasData: false, - isPythonExtensionInstalled: false, - needsGitInitialized: true, - projectInitialized: false, - pythonBinPath: undefined - }) - - expect(screen.getByText('DVC is not initialized')).toBeInTheDocument() - }) - - it('should offer to initialize Git if it is possible', () => { - render() - setData({ - canGitInitialize: true, - cliCompatible: true, - hasData: false, - isPythonExtensionInstalled: false, - needsGitInitialized: true, - projectInitialized: false, - pythonBinPath: undefined + it('should send a message to initialize the project when clicking the Initialize Project buttons when the project is not initialized', () => { + renderApp({ + canGitInitialize: false, + cliCompatible: true, + hasData: false, + isPythonExtensionInstalled: false, + isStudioConnected: false, + needsGitCommit: false, + needsGitInitialized: undefined, + projectInitialized: false, + pythonBinPath: undefined, + shareLiveToStudio: false + }) + + const initializeButton = screen.getByText('Initialize Project') + fireEvent.click(initializeButton) + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: MessageFromWebviewType.INITIALIZE_DVC + }) }) - const initializeButton = screen.getByText('Initialize Git') - expect(initializeButton).toBeInTheDocument() - fireEvent.click(initializeButton) - expect(mockPostMessage).toHaveBeenCalledWith({ - type: MessageFromWebviewType.INITIALIZE_GIT + it('should show a screen saying that the project contains no data if dvc is installed, the project is initialized but has no data', () => { + renderApp({ + canGitInitialize: false, + cliCompatible: true, + hasData: false, + isPythonExtensionInstalled: false, + isStudioConnected: false, + needsGitCommit: false, + needsGitInitialized: undefined, + projectInitialized: true, + pythonBinPath: undefined, + shareLiveToStudio: false + }) + + expect( + screen.getByText('Your project contains no data') + ).toBeInTheDocument() }) - setData({ - canGitInitialize: false, - cliCompatible: true, - hasData: false, - isPythonExtensionInstalled: false, - needsGitInitialized: true, - projectInitialized: false, - pythonBinPath: undefined + it('should not show a screen saying that the project contains no data if dvc is installed, the project is initialized and has data', () => { + renderApp({ + canGitInitialize: false, + cliCompatible: true, + hasData: true, + isPythonExtensionInstalled: false, + isStudioConnected: false, + needsGitCommit: false, + needsGitInitialized: undefined, + projectInitialized: true, + pythonBinPath: undefined, + shareLiveToStudio: false + }) + + expect( + screen.queryByText('Your project contains no data') + ).not.toBeInTheDocument() }) - - 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', () => { - render() - setData({ - canGitInitialize: undefined, - cliCompatible: true, - hasData: false, - isPythonExtensionInstalled: false, - needsGitInitialized: undefined, - projectInitialized: false, - pythonBinPath: undefined + describe('Studio not connected', () => { + it('should show three buttons which walk the user through saving a token', async () => { + renderApp({ + canGitInitialize: false, + cliCompatible: true, + hasData: false, + isPythonExtensionInstalled: true, + isStudioConnected: false, + needsGitCommit: false, + needsGitInitialized: false, + projectInitialized: true, + pythonBinPath: 'python', + shareLiveToStudio: false + }) + const buttons = await screen.findAllByRole('button') + expect(buttons).toHaveLength(3) }) - expect(screen.getByText('DVC is not initialized')).toBeInTheDocument() - }) - - it('should not show a screen saying that DVC is not initialized if the project is initialized and dvc is installed', () => { - render() - setData({ - canGitInitialize: undefined, - cliCompatible: true, - hasData: false, - isPythonExtensionInstalled: false, - needsGitInitialized: undefined, - projectInitialized: true, - pythonBinPath: undefined + it('should show a button which opens Studio', () => { + renderApp({ + canGitInitialize: false, + cliCompatible: true, + hasData: false, + isPythonExtensionInstalled: true, + isStudioConnected: false, + needsGitCommit: false, + needsGitInitialized: false, + projectInitialized: true, + pythonBinPath: 'python', + shareLiveToStudio: false + }) + + mockPostMessage.mockClear() + const button = screen.getByText('Sign In') + fireEvent.click(button) + expect(mockPostMessage).toHaveBeenCalledTimes(1) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: MessageFromWebviewType.OPEN_STUDIO + }) }) - expect(screen.queryByText('DVC is not initialized')).not.toBeInTheDocument() - }) - - it('should send a message to initialize the project when clicking the Initialize Project buttons when the project is not initialized', () => { - render() - setData({ - canGitInitialize: undefined, - cliCompatible: true, - hasData: false, - isPythonExtensionInstalled: false, - needsGitInitialized: undefined, - projectInitialized: false, - pythonBinPath: undefined + it("should show a button which opens the user's Studio profile", () => { + renderApp({ + canGitInitialize: false, + cliCompatible: true, + hasData: false, + isPythonExtensionInstalled: true, + isStudioConnected: false, + needsGitCommit: false, + needsGitInitialized: false, + projectInitialized: true, + pythonBinPath: 'python', + shareLiveToStudio: false + }) + + mockPostMessage.mockClear() + const button = screen.getByText('Get Token') + fireEvent.click(button) + expect(mockPostMessage).toHaveBeenCalledTimes(1) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: MessageFromWebviewType.OPEN_STUDIO_PROFILE + }) }) - const initializeButton = screen.getByText('Initialize Project') - fireEvent.click(initializeButton) - - expect(mockPostMessage).toHaveBeenCalledWith({ - type: MessageFromWebviewType.INITIALIZE_DVC + it("should show a button which allows the user's to save their Studio access token", () => { + renderApp({ + canGitInitialize: false, + cliCompatible: true, + hasData: false, + isPythonExtensionInstalled: true, + isStudioConnected: false, + needsGitCommit: false, + needsGitInitialized: false, + projectInitialized: true, + pythonBinPath: 'python', + shareLiveToStudio: false + }) + + mockPostMessage.mockClear() + const button = screen.getByText('Save') + fireEvent.click(button) + expect(mockPostMessage).toHaveBeenCalledTimes(1) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: MessageFromWebviewType.SAVE_STUDIO_TOKEN + }) }) }) - it('should show a screen saying that the project contains no data if dvc is installed, the project is initialized but has no data', () => { - render() - setData({ - canGitInitialize: undefined, - cliCompatible: true, - hasData: false, - isPythonExtensionInstalled: false, - needsGitInitialized: undefined, - projectInitialized: true, - pythonBinPath: undefined + describe('Studio connected', () => { + it('should render a checkbox which can be used to update dvc.studio.shareExperimentsLive', () => { + const shareExperimentsLive = false + renderApp({ + canGitInitialize: false, + cliCompatible: true, + hasData: false, + isPythonExtensionInstalled: true, + isStudioConnected: true, + needsGitCommit: false, + needsGitInitialized: false, + projectInitialized: true, + pythonBinPath: 'python', + shareLiveToStudio: shareExperimentsLive + }) + + mockPostMessage.mockClear() + const checkbox = screen.getByRole('checkbox') + fireEvent.click(checkbox) + expect(mockPostMessage).toHaveBeenCalledWith({ + payload: !shareExperimentsLive, + type: MessageFromWebviewType.SET_STUDIO_SHARE_EXPERIMENTS_LIVE + }) }) - expect( - screen.getByText('Your project contains no data') - ).toBeInTheDocument() - }) - - it('should not show a screen saying that the project contains no data if dvc is installed, the project is initialized and has data', () => { - render() - setData({ - canGitInitialize: undefined, - cliCompatible: true, - hasData: true, - isPythonExtensionInstalled: false, - needsGitInitialized: undefined, - projectInitialized: true, - pythonBinPath: undefined + it('should enable the user to update their studio token', () => { + renderApp({ + canGitInitialize: false, + cliCompatible: true, + hasData: false, + isPythonExtensionInstalled: true, + isStudioConnected: true, + needsGitCommit: false, + needsGitInitialized: false, + projectInitialized: true, + pythonBinPath: 'python', + shareLiveToStudio: false + }) + mockPostMessage.mockClear() + const button = screen.getByText('Update Token') + fireEvent.click(button) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: MessageFromWebviewType.SAVE_STUDIO_TOKEN + }) }) - - expect( - screen.queryByText('Your project contains no data') - ).not.toBeInTheDocument() }) }) diff --git a/webview/src/setup/components/App.tsx b/webview/src/setup/components/App.tsx index 5fd71a75a3..b733b04e6a 100644 --- a/webview/src/setup/components/App.tsx +++ b/webview/src/setup/components/App.tsx @@ -3,11 +3,16 @@ import { Section, SetupData } from 'dvc/src/setup/webview/contract' -import { MessageToWebview } from 'dvc/src/webview/contract' +import { + MessageFromWebviewType, + MessageToWebview +} from 'dvc/src/webview/contract' import React, { useCallback, useState } from 'react' -import { SetupExperiments } from './Experiments' -import { SectionContainer } from '../../shared/components/sectionContainer/SectionContainer' +import { Experiments } from './Experiments' +import { Studio } from './Studio' +import { SetupContainer } from './SetupContainer' import { useVsCodeMessaging } from '../../shared/hooks/useVsCodeMessaging' +import { sendMessage } from '../../shared/vscode' export const App: React.FC = () => { const [cliCompatible, setCliCompatible] = useState( @@ -31,6 +36,10 @@ export const App: React.FC = () => { typeof DEFAULT_SECTION_COLLAPSED >(DEFAULT_SECTION_COLLAPSED) + const [isStudioConnected, setIsStudioConnected] = useState(false) + const [shareLiveToStudio, setShareLiveToStudioValue] = + useState(false) + useVsCodeMessaging( useCallback( ({ data }: { data: MessageToWebview }) => { @@ -42,6 +51,8 @@ export const App: React.FC = () => { setNeedsGitCommit(data.data.needsGitCommit) setProjectInitialized(data.data.projectInitialized) setPythonBinPath(data.data.pythonBinPath) + setIsStudioConnected(data.data.isStudioConnected) + setShareLiveToStudioValue(data.data.shareLiveToStudio) }, [ setCanGitInitialized, @@ -51,33 +62,52 @@ export const App: React.FC = () => { setNeedsGitInitialized, setNeedsGitCommit, setProjectInitialized, - setPythonBinPath + setPythonBinPath, + setIsStudioConnected, + setShareLiveToStudioValue ] ) ) + const setShareLiveToStudio = (shouldShareLive: boolean) => { + setShareLiveToStudioValue(shouldShareLive) + sendMessage({ + payload: shouldShareLive, + type: MessageFromWebviewType.SET_STUDIO_SHARE_EXPERIMENTS_LIVE + }) + } + return ( - - setSectionCollapsed({ - ...sectionCollapsed, - [Section.EXPERIMENTS]: !sectionCollapsed[Section.EXPERIMENTS] - }) - } - > - - + <> + + + + + + + ) } diff --git a/webview/src/setup/components/CliIncompatible.tsx b/webview/src/setup/components/CliIncompatible.tsx index d625205701..ae67b486a0 100644 --- a/webview/src/setup/components/CliIncompatible.tsx +++ b/webview/src/setup/components/CliIncompatible.tsx @@ -8,7 +8,7 @@ type CliIncompatibleProps = { checkCompatibility: () => void } export const CliIncompatible: React.FC = ({ checkCompatibility }) => ( - +

DVC is incompatible

The located CLI is incompatible with the extension.

diff --git a/webview/src/setup/components/CliUnavailable.tsx b/webview/src/setup/components/CliUnavailable.tsx index 525b2177db..880c7aa93e 100644 --- a/webview/src/setup/components/CliUnavailable.tsx +++ b/webview/src/setup/components/CliUnavailable.tsx @@ -56,7 +56,7 @@ export const CliUnavailable: React.FC = ({ if (!canInstall) { return ( - + <p>DVC & DVCLive cannot be auto-installed as Python was not located.</p> <SetupWorkspace description="To locate a Python Interpreter or DVC." /> @@ -65,7 +65,7 @@ export const CliUnavailable: React.FC<CliUnavailableProps> = ({ } return ( - <EmptyState> + <EmptyState isFullScreen={false}> <Title /> <OfferToInstall pythonBinPath={pythonBinPath} installDvc={installDvc}> {isPythonExtensionInstalled ? ( diff --git a/webview/src/setup/components/Experiments.tsx b/webview/src/setup/components/Experiments.tsx index f34d189367..de02909e74 100644 --- a/webview/src/setup/components/Experiments.tsx +++ b/webview/src/setup/components/Experiments.tsx @@ -15,7 +15,7 @@ import { NeedsGitCommit } from './NeedsGitCommit' import { NoData } from './NoData' import { EmptyState } from '../../shared/components/emptyState/EmptyState' -export type SetupExperimentsProps = { +export type ExperimentsProps = { canGitInitialize: boolean | undefined cliCompatible: boolean | undefined hasData: boolean | undefined @@ -26,7 +26,7 @@ export type SetupExperimentsProps = { pythonBinPath: string | undefined } -export const SetupExperiments: React.FC<SetupExperimentsProps> = ({ +export const Experiments: React.FC<ExperimentsProps> = ({ canGitInitialize, cliCompatible, hasData, @@ -69,12 +69,16 @@ export const SetupExperiments: React.FC<SetupExperimentsProps> = ({ } if (hasData === undefined) { - return <EmptyState>Loading Project...</EmptyState> + return <EmptyState isFullScreen={false}>Loading Project...</EmptyState> } if (!hasData) { return <NoData /> } - return <EmptyState>{"You're all setup"}</EmptyState> + return ( + <EmptyState isFullScreen={false}> + <h1>{"You're all setup"}</h1> + </EmptyState> + ) } diff --git a/webview/src/setup/components/NeedsGitCommit.tsx b/webview/src/setup/components/NeedsGitCommit.tsx index 2490090695..77ab366eb1 100644 --- a/webview/src/setup/components/NeedsGitCommit.tsx +++ b/webview/src/setup/components/NeedsGitCommit.tsx @@ -7,7 +7,7 @@ type NeedsGitCommitProps = { showScmPanel: () => void } export const NeedsGitCommit: React.FC<NeedsGitCommitProps> = ({ showScmPanel }) => ( - <EmptyState> + <EmptyState isFullScreen={false}> <div> <h1>No Git commits detected</h1> <p> diff --git a/webview/src/setup/components/NoData.tsx b/webview/src/setup/components/NoData.tsx index 83d7ac5675..4d940ad002 100644 --- a/webview/src/setup/components/NoData.tsx +++ b/webview/src/setup/components/NoData.tsx @@ -10,7 +10,7 @@ import { EmptyState } from '../../shared/components/emptyState/EmptyState' export const NoData: React.FC = () => { return ( - <EmptyState> + <EmptyState isFullScreen={false}> <h1>Your project contains no data</h1> <div> Enable DVC experiment tracking using{' '} diff --git a/webview/src/setup/components/ProjectUninitialized.tsx b/webview/src/setup/components/ProjectUninitialized.tsx index 989e1bf674..014282c67f 100644 --- a/webview/src/setup/components/ProjectUninitialized.tsx +++ b/webview/src/setup/components/ProjectUninitialized.tsx @@ -19,7 +19,7 @@ const GitUninitialized: React.FC<GitUninitializedProps> = ({ }) => { if (!canGitInitialize) { return ( - <EmptyState> + <EmptyState isFullScreen={false}> <Header /> <GitIsPrerequisite /> <p> @@ -35,7 +35,7 @@ const GitUninitialized: React.FC<GitUninitializedProps> = ({ } return ( - <EmptyState> + <EmptyState isFullScreen={false}> <Header /> <GitIsPrerequisite /> <Button onClick={initializeGit} text="Initialize Git" /> @@ -46,7 +46,7 @@ const GitUninitialized: React.FC<GitUninitializedProps> = ({ const DvcUninitialized: React.FC<{ initializeDvc: () => void }> = ({ initializeDvc }) => ( - <EmptyState> + <EmptyState isFullScreen={false}> <Header /> <p> The current workspace does not contain a DVC project. You can initialize a diff --git a/webview/src/setup/components/SetupContainer.tsx b/webview/src/setup/components/SetupContainer.tsx new file mode 100644 index 0000000000..c2f5b781c4 --- /dev/null +++ b/webview/src/setup/components/SetupContainer.tsx @@ -0,0 +1,34 @@ +import { + DEFAULT_SECTION_COLLAPSED, + Section +} from 'dvc/src/setup/webview/contract' +import React from 'react' +import { SectionContainer } from '../../shared/components/sectionContainer/SectionContainer' + +export const SetupContainer: React.FC<{ + children: React.ReactNode + sectionCollapsed: typeof DEFAULT_SECTION_COLLAPSED + sectionKey: Section + setSectionCollapsed: (value: typeof DEFAULT_SECTION_COLLAPSED) => void + title: string +}> = ({ + children, + sectionCollapsed, + sectionKey, + setSectionCollapsed, + title +}) => ( + <SectionContainer + sectionCollapsed={sectionCollapsed[sectionKey]} + sectionKey={sectionKey} + title={title} + onToggleSection={() => + setSectionCollapsed({ + ...sectionCollapsed, + [sectionKey]: !sectionCollapsed[sectionKey] + }) + } + > + {children} + </SectionContainer> +) diff --git a/webview/src/connect/components/Studio.tsx b/webview/src/setup/components/Studio.tsx similarity index 88% rename from webview/src/connect/components/Studio.tsx rename to webview/src/setup/components/Studio.tsx index d9159a72f5..d9a745c584 100644 --- a/webview/src/connect/components/Studio.tsx +++ b/webview/src/setup/components/Studio.tsx @@ -1,6 +1,6 @@ import React from 'react' import { VSCodeCheckbox } from '@vscode/webview-ui-toolkit/react' -import { ConnectData, STUDIO_URL } from 'dvc/src/connect/webview/contract' +import { STUDIO_URL } from 'dvc/src/setup/webview/contract' import { openStudio, openStudioProfile, @@ -12,7 +12,7 @@ import { Button } from '../../shared/components/button/Button' const Connect: React.FC = () => { return ( - <EmptyState> + <EmptyState isFullScreen={false}> <div> <h1> Connect to <a href={STUDIO_URL}>Studio</a> @@ -59,7 +59,7 @@ const Settings: React.FC<{ setShareLiveToStudio: (shareLiveToStudio: boolean) => void }> = ({ shareLiveToStudio, setShareLiveToStudio }) => { return ( - <EmptyState> + <EmptyState isFullScreen={false}> <div> <h1>Studio Settings</h1> <p> @@ -95,9 +95,11 @@ const Settings: React.FC<{ ) } -export const Studio: React.FC< - ConnectData & { setShareLiveToStudio: (shareLiveToStudio: boolean) => void } -> = ({ isStudioConnected, shareLiveToStudio, setShareLiveToStudio }) => { +export const Studio: React.FC<{ + isStudioConnected: boolean + shareLiveToStudio: boolean + setShareLiveToStudio: (shareLiveToStudio: boolean) => void +}> = ({ isStudioConnected, shareLiveToStudio, setShareLiveToStudio }) => { return isStudioConnected ? ( <Settings shareLiveToStudio={shareLiveToStudio} diff --git a/webview/src/setup/components/messages.ts b/webview/src/setup/components/messages.ts index 8ce16b3292..2b60384219 100644 --- a/webview/src/setup/components/messages.ts +++ b/webview/src/setup/components/messages.ts @@ -32,3 +32,15 @@ export const selectPythonInterpreter = () => { export const setupWorkspace = () => { sendMessage({ type: MessageFromWebviewType.SETUP_WORKSPACE }) } + +export const openStudio = () => + sendMessage({ type: MessageFromWebviewType.OPEN_STUDIO }) + +export const openStudioProfile = () => + sendMessage({ type: MessageFromWebviewType.OPEN_STUDIO_PROFILE }) + +export const saveStudioToken = () => + sendMessage({ type: MessageFromWebviewType.SAVE_STUDIO_TOKEN }) + +export const removeStudioToken = () => + sendMessage({ type: MessageFromWebviewType.REMOVE_STUDIO_TOKEN }) diff --git a/webview/src/shared/components/emptyState/styles.module.scss b/webview/src/shared/components/emptyState/styles.module.scss index 0613e16a2b..1deb3a0cfd 100644 --- a/webview/src/shared/components/emptyState/styles.module.scss +++ b/webview/src/shared/components/emptyState/styles.module.scss @@ -7,7 +7,8 @@ .emptySection { width: 100%; - height: 33vh; + min-height: 33vh; + height: auto; } .emptyStateText { diff --git a/webview/src/shared/components/sectionContainer/SectionContainer.tsx b/webview/src/shared/components/sectionContainer/SectionContainer.tsx index c2b80228d6..4b9171e447 100644 --- a/webview/src/shared/components/sectionContainer/SectionContainer.tsx +++ b/webview/src/shared/components/sectionContainer/SectionContainer.tsx @@ -1,7 +1,10 @@ import cx from 'classnames' import React, { MouseEvent } from 'react' import { Section as PlotsSection } from 'dvc/src/plots/webview/contract' -import { Section as SetupSection } from 'dvc/src/setup/webview/contract' +import { + STUDIO_URL, + Section as SetupSection +} from 'dvc/src/setup/webview/contract' import styles from './styles.module.scss' import { Icon } from '../Icon' import { ChevronDown, ChevronRight, Info } from '../icons' @@ -66,6 +69,15 @@ export const SectionDescription = { </a> . </span> + ), + // Setup Experiments + [SetupSection.STUDIO]: ( + <span data-testid="tooltip-setup-studio"> + {"Configure the extension's connection to "} + <a href={STUDIO_URL}>Studio</a>.<br /> + Studio provides a collaboration platform for Machine Learning and is free + for small teams and individual contributors. + </span> ) } as const diff --git a/webview/src/stories/Connect.stories.tsx b/webview/src/stories/Connect.stories.tsx deleted file mode 100644 index b26747adb9..0000000000 --- a/webview/src/stories/Connect.stories.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Story, Meta } from '@storybook/react/types-6-0' -import React, { useState } from 'react' -import { DISABLE_CHROMATIC_SNAPSHOTS } from './util' - -import './test-vscode-styles.scss' -import '../shared/style.scss' -import { Studio } from '../connect/components/Studio' - -export default { - args: { - data: {} - }, - component: Studio, - parameters: DISABLE_CHROMATIC_SNAPSHOTS, - title: 'Connect' -} as Meta - -const Template: Story = ({ - isStudioConnected, - shareLiveToStudio: initialShareLiveToStudio -}) => { - const [shareLiveToStudio, setShareLiveToStudio] = useState<boolean>( - !!initialShareLiveToStudio - ) - return ( - <Studio - isStudioConnected={isStudioConnected} - shareLiveToStudio={shareLiveToStudio} - setShareLiveToStudio={setShareLiveToStudio} - /> - ) -} - -export const ConnectToStudio = Template.bind({}) -ConnectToStudio.args = { isStudioConnected: false } - -export const StudioSettings = Template.bind({}) -StudioSettings.args = { isStudioConnected: true, shareLiveToStudio: true } diff --git a/webview/webpack.config.ts b/webview/webpack.config.ts index 68c6cec109..febb1f758d 100644 --- a/webview/webpack.config.ts +++ b/webview/webpack.config.ts @@ -24,7 +24,6 @@ export default { devServer, devtool: 'source-map', entry: { - connect: { dependOn: 'react', import: r('src/connect/index.tsx') }, experiments: { dependOn: 'react', import: r('src/experiments/index.tsx') }, plots: { dependOn: 'react', import: r('src/plots/index.tsx') }, react: ['react', 'react-dom'],