diff --git a/.vscode/settings.json b/.vscode/settings.json index f35eed42cf..cd361db480 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -24,6 +24,7 @@ "featurize", "hardlinks", "Interactors", + "isat", "isdir", "isempty", "isequal", diff --git a/extension/package.json b/extension/package.json index e55b20d431..6d113c8072 100644 --- a/extension/package.json +++ b/extension/package.json @@ -112,6 +112,11 @@ "category": "DVC", "icon": "$(star-full)" }, + { + "title": "%command.addStudioAccessToken%", + "command": "dvc.addStudioAccessToken", + "category": "DVC" + }, { "title": "%command.addTarget%", "command": "dvc.addTarget", @@ -307,6 +312,11 @@ "category": "DVC", "icon": "$(close-all)" }, + { + "title": "%command.removeStudioAccessToken%", + "command": "dvc.removeStudioAccessToken", + "category": "DVC" + }, { "title": "%command.removeTarget%", "command": "dvc.removeTarget", @@ -384,6 +394,11 @@ "command": "dvc.showCommands", "category": "DVC" }, + { + "title": "%command.showConnect%", + "command": "dvc.showConnect", + "category": "DVC" + }, { "title": "%command.showExperiments%", "command": "dvc.showExperiments", @@ -622,6 +637,10 @@ "command": "dvc.addStarredExperimentsTableSort", "when": "dvc.commands.available && dvc.project.available" }, + { + "command": "dvc.addStudioAccessToken", + "when": "dvc.commands.available && dvc.project.available" + }, { "command": "dvc.addTarget", "when": "false" @@ -750,6 +769,10 @@ "command": "dvc.removeExperimentsTableSorts", "when": "dvc.commands.available && dvc.project.available" }, + { + "command": "dvc.removeStudioAccessToken", + "when": "dvc.commands.available && dvc.project.available && dvc.studio.connected" + }, { "command": "dvc.removeTarget", "when": "false" @@ -786,6 +809,10 @@ "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.selectForCompare", "when": "false" @@ -1319,6 +1346,11 @@ "name": "Actions", "when": "true" }, + { + "id": "dvc.views.studio", + "name": "Studio", + "when": "!dvc.studio.connected" + }, { "id": "dvc.views.experimentsColumnsTree", "name": "Columns", @@ -1355,6 +1387,11 @@ "view": "dvc.views.actions", "contents": "[$(beaker) Show Experiments](command:dvc.showExperiments)\n[$(graph-scatter) Show Plots](command:dvc.showPlots)\n[$(play) Run Experiment](command:dvc.runExperiment)" }, + { + "view": "dvc.views.studio", + "contents": "[$(plug) Connect](command:dvc.showConnect)", + "when": "!dvc.studio.connected" + }, { "view": "dvc.views.welcome", "contents": "New to the extension?\n[Show Walkthrough](command:dvc.getStarted)\n\nThe extension is currently unable to initialize.\n[Show Setup](command:dvc.showSetup)", diff --git a/extension/package.nls.json b/extension/package.nls.json index 4a5e22a10e..08d54467c5 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -5,6 +5,7 @@ "command.addExperimentsTableSort": "Add Or Update Sort On Experiments Table", "command.addStarredExperimentsTableFilter": "Filter Experiments Table to Starred", "command.addStarredExperimentsTableSort": "Sort Experiments Table by Starred", + "command.addStudioAccessToken": "Add Studio Access Token", "command.addTarget": "Add Target", "command.applyExperiment": "Apply Experiment To Workspace", "command.branchExperiment": "Create New Branch From Experiment", @@ -39,6 +40,7 @@ "command.removeExperimentQueue": "Remove All Queued Experiments", "command.removeExperimentsTableFilters": "Remove Filter(s) From Experiments Table", "command.removeExperimentsTableSorts": "Remove Sort(s) From Experiments Table", + "command.removeStudioAccessToken": "Remove Studio Access Token", "command.removeTarget": "Remove", "command.renameTarget": "Rename", "command.runExperiment": "Run Experiment", @@ -50,6 +52,7 @@ "command.shareExperimentAsBranch": "Share Experiment as Branch", "command.shareExperimentAsCommit": "Commit and Share Experiment", "command.showCommands": "Show Commands", + "command.showConnect": "Connect to Studio", "command.showExperiments": "Show Experiments", "command.showExperimentsAndPlots": "Show Experiments and Plots", "command.showOutput": "Show DVC Output", diff --git a/extension/src/commands/external.ts b/extension/src/commands/external.ts index 9d546972f4..fef98976c6 100644 --- a/extension/src/commands/external.ts +++ b/extension/src/commands/external.ts @@ -95,5 +95,9 @@ export enum RegisteredCommands { TRACKED_EXPLORER_SELECT_FOR_COMPARE = 'dvc.selectForCompare', SETUP_SHOW = 'dvc.showSetup', - SELECT_FOCUSED_PROJECTS = 'dvc.selectFocusedProjects' + SELECT_FOCUSED_PROJECTS = 'dvc.selectFocusedProjects', + + CONNECT_SHOW = 'dvc.showConnect', + ADD_STUDIO_ACCESS_TOKEN = 'dvc.addStudioAccessToken', + REMOVE_STUDIO_ACCESS_TOKEN = 'dvc.removeStudioAccessToken' } diff --git a/extension/src/connect/index.ts b/extension/src/connect/index.ts new file mode 100644 index 0000000000..f79f7314a8 --- /dev/null +++ b/extension/src/connect/index.ts @@ -0,0 +1,124 @@ +import { commands, ExtensionContext, SecretStorage } from 'vscode' +import { validateTokenInput } from './inputBox' +import { STUDIO_ACCESS_TOKEN_KEY, isStudioAccessToken } from './token' +import { 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 { getInput, 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 { showInformation } from '../vscode/modal' + +export class Connect extends BaseRepository { + public readonly viewKey = ViewKey.CONNECT + + private readonly secrets: SecretStorage + + constructor(context: ExtensionContext, webviewIcon: Resource) { + super('', webviewIcon) + + this.secrets = context.secrets + + this.dispose.track( + this.onDidReceivedWebviewMessage(message => + this.handleMessageFromWebview(message) + ) + ) + + void this.setContext().then(() => this.deferred.resolve()) + + this.dispose.track( + context.secrets.onDidChange(e => { + if (e.key !== STUDIO_ACCESS_TOKEN_KEY) { + return + } + return this.setContext() + }) + ) + } + + public sendInitialWebviewData(): void {} + + 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) + } + + 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 + ) + default: + Logger.error(`Unexpected message: ${JSON.stringify(message)}`) + } + } + + private openStudio() { + return openUrl(STUDIO_URL) + } + + private async openStudioProfile() { + const username = await getInput(Title.ENTER_STUDIO_USERNAME) + if (!username) { + return + } + return openUrl(`${STUDIO_URL}/user/${username}/profile`) + } + + private async setContext() { + const storedToken = await this.getSecret(STUDIO_ACCESS_TOKEN_KEY) + if (isStudioAccessToken(storedToken)) { + if (this.deferred.state === 'resolved') { + void showInformation( + 'Studio is now connected. Use the "Share to Studio" command from an experiment\'s context menu to share experiments.' + ) + } + this.webview?.dispose() + return setContextValue(ContextKey.STUDIO_CONNECTED, true) + } + + return setContextValue(ContextKey.STUDIO_CONNECTED, false) + } + + 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/connect/inputBox.test.ts new file mode 100644 index 0000000000..e1d3214421 --- /dev/null +++ b/extension/src/connect/inputBox.test.ts @@ -0,0 +1,20 @@ +import { validateTokenInput } from './inputBox' + +describe('validateTokenInput', () => { + const mockedStudioAccessToken = + 'isat_1Z4T0zVHvq9Cu03XEe9Zjvx2vkBihfGPdY7FfmEMAagOXfQx' + it('should return the warning if the input is not valid', () => { + expect( + validateTokenInput(mockedStudioAccessToken.slice(0, -1)) + ).not.toBeNull() + expect( + validateTokenInput( + mockedStudioAccessToken.slice(1, mockedStudioAccessToken.length) + ) + ).not.toBeNull() + }) + + it('should return null if the input is valid', () => { + expect(validateTokenInput(mockedStudioAccessToken)).toBeNull() + }) +}) diff --git a/extension/src/connect/inputBox.ts b/extension/src/connect/inputBox.ts new file mode 100644 index 0000000000..c3c12893ca --- /dev/null +++ b/extension/src/connect/inputBox.ts @@ -0,0 +1,8 @@ +import { isStudioAccessToken } from './token' + +export const validateTokenInput = (input: string | undefined) => { + if (!isStudioAccessToken(input)) { + return 'please enter a valid Studio access token' + } + return null +} diff --git a/extension/src/connect/register.ts b/extension/src/connect/register.ts new file mode 100644 index 0000000000..12d9d3422d --- /dev/null +++ b/extension/src/connect/register.ts @@ -0,0 +1,23 @@ +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.ADD_STUDIO_ACCESS_TOKEN, + () => connect.saveStudioAccessToken() + ) + + internalCommands.registerExternalCommand( + RegisteredCommands.REMOVE_STUDIO_ACCESS_TOKEN, + () => connect.removeStudioAccessToken() + ) +} diff --git a/extension/src/connect/token.ts b/extension/src/connect/token.ts new file mode 100644 index 0000000000..d3434c4fcd --- /dev/null +++ b/extension/src/connect/token.ts @@ -0,0 +1,8 @@ +export const STUDIO_ACCESS_TOKEN_KEY = 'dvc.studioAccessToken' + +export const isStudioAccessToken = (text?: string): boolean => { + if (!text) { + return false + } + return text.startsWith('isat_') && text.length >= 53 +} diff --git a/extension/src/connect/webview/contract.ts b/extension/src/connect/webview/contract.ts new file mode 100644 index 0000000000..862ff04481 --- /dev/null +++ b/extension/src/connect/webview/contract.ts @@ -0,0 +1 @@ +export const STUDIO_URL = 'https://studio.iterative.ai' diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 849bf462b7..0c433e8748 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -9,6 +9,8 @@ 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' @@ -52,12 +54,12 @@ import { stopProcesses } from './processExecution' import { Flag } from './cli/dvc/constants' import { LanguageClient } from './languageClient' import { collectRunningExperimentPids } from './experiments/processExecution/collect' - export class Extension extends Disposable { protected readonly internalCommands: InternalCommands private readonly resourceLocator: ResourceLocator private readonly repositories: WorkspaceRepositories + private readonly connect: Connect private readonly experiments: WorkspaceExperiments private readonly plots: WorkspacePlots private readonly setup: Setup @@ -85,6 +87,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.dvcExecutor = this.dispose.track(new DvcExecutor(config)) this.dvcReader = this.dispose.track(new DvcReader(config)) this.dvcRunner = this.dispose.track(new DvcRunner(config)) @@ -185,6 +191,8 @@ export class Extension extends Disposable { ) ) + registerConnectCommands(this.connect, this.internalCommands) + registerExperimentCommands( this.experiments, this.internalCommands, diff --git a/extension/src/telemetry/constants.ts b/extension/src/telemetry/constants.ts index 2ec4668d7e..e077a5369c 100644 --- a/extension/src/telemetry/constants.ts +++ b/extension/src/telemetry/constants.ts @@ -27,6 +27,10 @@ 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', @@ -208,6 +212,10 @@ 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 @@ -273,4 +281,8 @@ export interface IEventNamePropertyMapping { [EventName.SETUP_SHOW]: undefined [EventName.SELECT_FOCUSED_PROJECTS]: undefined + + [EventName.CONNECT_SHOW]: undefined + [EventName.ADD_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 new file mode 100644 index 0000000000..5fb3f4c171 --- /dev/null +++ b/extension/src/test/suite/connect/index.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, suite, describe, it } from 'mocha' +import { expect } from 'chai' +import { restore, spy, stub } from 'sinon' +import { + EventEmitter, + ExtensionContext, + SecretStorage, + Uri, + commands, + env, + window +} 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' + +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(), 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 mockUsername = 'username-something-something' + const { mockMessageReceived, mockOpenExternal, urlOpenedEvent } = + await buildConnect() + + stub(window, 'showInputBox').resolves(mockUsername) + + mockMessageReceived.fire({ + type: MessageFromWebviewType.OPEN_STUDIO_PROFILE + }) + + await urlOpenedEvent + expect(mockOpenExternal).to.be.calledWith( + Uri.parse(`https://studio.iterative.ai/user/${mockUsername}/profile`) + ) + }).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 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/util.ts b/extension/src/test/suite/util.ts index 2d9a468d07..4394431bde 100644 --- a/extension/src/test/suite/util.ts +++ b/extension/src/test/suite/util.ts @@ -173,6 +173,9 @@ export const buildMockData = ( update } as unknown as T) +export const buildResourceLocator = (disposer: Disposer): ResourceLocator => + disposer.track(new ResourceLocator(extensionUri)) + export const buildDependencies = ( disposer: Disposer, expShow = expShowFixture, @@ -208,7 +211,7 @@ export const buildDependencies = ( const updatesPaused = disposer.track(new EventEmitter()) - const resourceLocator = disposer.track(new ResourceLocator(extensionUri)) + const resourceLocator = buildResourceLocator(disposer) const messageSpy = spy(BaseWebview.prototype, 'show') diff --git a/extension/src/vscode/context.ts b/extension/src/vscode/context.ts index 71046850db..bb8d1501af 100644 --- a/extension/src/vscode/context.ts +++ b/extension/src/vscode/context.ts @@ -1,8 +1,9 @@ import { commands } from 'vscode' export enum ContextKey { - COMMANDS_AVAILABLE = 'dvc.commands.available', CLI_INCOMPATIBLE = 'dvc.cli.incompatible', + COMMANDS_AVAILABLE = 'dvc.commands.available', + CONNECT_WEBVIEW_ACTIVE = 'dvc.connect.webview.active', EXPERIMENT_CHECKPOINTS = 'dvc.experiment.checkpoints', EXPERIMENT_FILTERS_SELECTED = 'dvc.experiments.filter.selected', EXPERIMENTS_WEBVIEW_ACTIVE = 'dvc.experiments.webview.active', @@ -15,7 +16,8 @@ export enum ContextKey { PROJECT_AVAILABLE = 'dvc.project.available', PROJECT_HAS_DATA = 'dvc.project.hasData', SCM_RUNNING = 'dvc.scm.command.running', - SETUP_WEBVIEW_ACTIVE = 'dvc.setup.webview.active' + SETUP_WEBVIEW_ACTIVE = 'dvc.setup.webview.active', + STUDIO_CONNECTED = 'dvc.studio.connected' } export const setContextValue = (key: ContextKey, value: unknown) => diff --git a/extension/src/vscode/external.ts b/extension/src/vscode/external.ts new file mode 100644 index 0000000000..21e4ba2517 --- /dev/null +++ b/extension/src/vscode/external.ts @@ -0,0 +1,3 @@ +import { env, Uri } from 'vscode' + +export const openUrl = (url: string) => env.openExternal(Uri.parse(url)) diff --git a/extension/src/vscode/inputBox.ts b/extension/src/vscode/inputBox.ts index 2711a6a5b1..df8109fde7 100644 --- a/extension/src/vscode/inputBox.ts +++ b/extension/src/vscode/inputBox.ts @@ -15,9 +15,10 @@ export const getInput = ( export const getValidInput = ( title: Title, validateInput: (text?: string) => null | string, - options?: { prompt?: string; value?: string } + options?: { prompt?: string; value?: string; password?: boolean } ): Thenable => window.showInputBox({ + password: options?.password, prompt: options?.prompt, title, validateInput, diff --git a/extension/src/vscode/modal.ts b/extension/src/vscode/modal.ts index e584c6432b..fcd68c985e 100644 --- a/extension/src/vscode/modal.ts +++ b/extension/src/vscode/modal.ts @@ -6,3 +6,9 @@ export const warnOfConsequences = ( ...items: Response[] ): Thenable => window.showWarningMessage(text, { modal: true }, ...items) + +export const showInformation = ( + text: string, + ...items: Response[] +): Thenable => + window.showInformationMessage(text, { modal: true }, ...items) diff --git a/extension/src/vscode/title.ts b/extension/src/vscode/title.ts index ee309d575c..179d7f81c4 100644 --- a/extension/src/vscode/title.ts +++ b/extension/src/vscode/title.ts @@ -7,6 +7,8 @@ export enum Title { ENTER_FILTER_VALUE = 'Enter a Filter Value', ENTER_RELATIVE_DESTINATION = 'Enter a Destination Relative to the Root', ENTER_PATH_OR_CHOOSE_FILE = 'Enter the path to your training script or select it', + ENTER_STUDIO_USERNAME = 'Enter your Studio username', + ENTER_STUDIO_TOKEN = 'Enter your Studio access token', ENTER_STAGE_NAME = 'Enter a name for the main stage of your pipeline', GARBAGE_COLLECT_EXPERIMENTS = 'Garbage Collect Experiments', SHOW_SETUP = 'Show Setup', diff --git a/extension/src/webview/constants.ts b/extension/src/webview/constants.ts index 762e1c81b2..95526a1395 100644 --- a/extension/src/webview/constants.ts +++ b/extension/src/webview/constants.ts @@ -1,8 +1,16 @@ -import { distPath, react, experiments, plots, setup } from 'dvc-vscode-webview' +import { + distPath, + connect, + 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' @@ -25,6 +33,18 @@ 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 ab6669f665..186d807bca 100644 --- a/extension/src/webview/contract.ts +++ b/extension/src/webview/contract.ts @@ -8,7 +8,7 @@ import { } from '../plots/webview/contract' import { SetupData } from '../setup/webview/contract' -export type WebviewData = TableData | PlotsData | SetupData +export type WebviewData = TableData | PlotsData | SetupData | {} export enum MessageFromWebviewType { INITIALIZED = 'initialized', @@ -18,8 +18,10 @@ export enum MessageFromWebviewType { CREATE_BRANCH_FROM_EXPERIMENT = 'create-branch-from-experiment', FOCUS_FILTERS_TREE = 'focus-filters-tree', FOCUS_SORTS_TREE = 'focus-sorts-tree', - OPEN_PLOTS_WEBVIEW = 'open-plots-webview', OPEN_PARAMS_FILE_TO_THE_SIDE = 'open-params-file-to-the-side', + OPEN_PLOTS_WEBVIEW = 'open-plots-webview', + OPEN_STUDIO = 'open-studio', + OPEN_STUDIO_PROFILE = 'open-studio-profile', REMOVE_COLUMN_SORT = 'remove-column-sort', REMOVE_EXPERIMENT = 'remove-experiment', REORDER_COLUMNS = 'reorder-columns', @@ -31,6 +33,7 @@ export enum MessageFromWebviewType { REFRESH_REVISIONS = 'refresh-revisions', RESIZE_COLUMN = 'resize-column', RESIZE_PLOTS = 'resize-plots', + SAVE_STUDIO_TOKEN = 'save-studio-token', STOP_EXPERIMENT = 'stop-experiment', SORT_COLUMN = 'sort-column', TOGGLE_EXPERIMENT = 'toggle-experiment', @@ -197,6 +200,9 @@ export type MessageFromWebview = | { type: MessageFromWebviewType.SHOW_SCM_PANEL } | { type: MessageFromWebviewType.INSTALL_DVC } | { type: MessageFromWebviewType.SETUP_WORKSPACE } + | { type: MessageFromWebviewType.OPEN_STUDIO } + | { type: MessageFromWebviewType.OPEN_STUDIO_PROFILE } + | { type: MessageFromWebviewType.SAVE_STUDIO_TOKEN } | { type: MessageFromWebviewType.ADD_CONFIGURATION } export type MessageToWebview = { diff --git a/extension/src/webview/repository.ts b/extension/src/webview/repository.ts index 0b68880d79..2cb8349e04 100644 --- a/extension/src/webview/repository.ts +++ b/extension/src/webview/repository.ts @@ -48,7 +48,7 @@ export abstract class BaseRepository< this.dvcRoot, this.webviewIcon, viewColumn, - this.viewKey === ViewKey.SETUP + [ViewKey.CONNECT, ViewKey.SETUP].includes(this.viewKey) ) this.setWebview(webview) diff --git a/webview/.eslintrc.js b/webview/.eslintrc.js index 250f453848..308dfa66be 100644 --- a/webview/.eslintrc.js +++ b/webview/.eslintrc.js @@ -22,6 +22,7 @@ module.exports = { }, { files: [ + 'src/connect/index.tsx', 'src/experiments/index.tsx', 'src/plots/index.tsx', 'src/shared/components/icons/index.ts', diff --git a/webview/index.d.ts b/webview/index.d.ts index 15d44552d4..f056f41cba 100644 --- a/webview/index.d.ts +++ b/webview/index.d.ts @@ -1,3 +1,4 @@ +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 bfb661e5ab..f45978b6fb 100644 --- a/webview/index.js +++ b/webview/index.js @@ -6,6 +6,7 @@ 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 new file mode 100644 index 0000000000..bebb930f17 --- /dev/null +++ b/webview/src/connect/components/App.test.tsx @@ -0,0 +1,64 @@ +import React from 'react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { MessageFromWebviewType } from 'dvc/src/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 renderApp = () => { + return render() +} + +describe('App', () => { + 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 + }) + }) +}) diff --git a/webview/src/connect/components/App.tsx b/webview/src/connect/components/App.tsx new file mode 100644 index 0000000000..bef22f02c0 --- /dev/null +++ b/webview/src/connect/components/App.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import { Studio } from './Studio' +import { useVsCodeMessaging } from '../../shared/hooks/useVsCodeMessaging' + +export const App: React.FC = () => { + useVsCodeMessaging() + + return +} diff --git a/webview/src/connect/components/Studio.tsx b/webview/src/connect/components/Studio.tsx new file mode 100644 index 0000000000..be0230f14d --- /dev/null +++ b/webview/src/connect/components/Studio.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import { STUDIO_URL } from 'dvc/src/connect/webview/contract' +import { openStudio, openStudioProfile, saveStudioToken } from './messages' +import { EmptyState } from '../../shared/components/emptyState/EmptyState' +import { Button } from '../../shared/components/button/Button' + +export const Studio: React.FC = () => { + return ( + +
+

+ Connect to Studio +

+

+ Share experiments and plots with collaborators directly from your IDE. +

+

+ An{' '} + + access token + {' '} + can be generated from your Studio profile page. +

+
+
+ ) +} diff --git a/webview/src/connect/components/messages.ts b/webview/src/connect/components/messages.ts new file mode 100644 index 0000000000..d37c831606 --- /dev/null +++ b/webview/src/connect/components/messages.ts @@ -0,0 +1,11 @@ +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 }) diff --git a/webview/src/connect/index.tsx b/webview/src/connect/index.tsx new file mode 100644 index 0000000000..95de9279c6 --- /dev/null +++ b/webview/src/connect/index.tsx @@ -0,0 +1,7 @@ +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/shared/hooks/useVsCodeMessaging.ts b/webview/src/shared/hooks/useVsCodeMessaging.ts index 028f5a9188..052c8cdd21 100644 --- a/webview/src/shared/hooks/useVsCodeMessaging.ts +++ b/webview/src/shared/hooks/useVsCodeMessaging.ts @@ -10,13 +10,13 @@ const signalInitialized = () => sendMessage({ type: MessageFromWebviewType.INITIALIZED }) export function useVsCodeMessaging( - handler: (event: { data: MessageToWebview }) => void + handler?: (event: { data: MessageToWebview }) => void ) { useEffect(() => { signalInitialized() }, []) useEffect(() => { - window.addEventListener('message', handler) - return () => window.removeEventListener('message', handler) + handler && window.addEventListener('message', handler) + return () => handler && window.removeEventListener('message', handler) }, [handler]) } diff --git a/webview/src/stories/Connect.stories.tsx b/webview/src/stories/Connect.stories.tsx new file mode 100644 index 0000000000..40019c31ed --- /dev/null +++ b/webview/src/stories/Connect.stories.tsx @@ -0,0 +1,20 @@ +import { Story, Meta } from '@storybook/react/types-6-0' +import React from 'react' + +import './test-vscode-styles.scss' +import '../shared/style.scss' +import { App } from '../connect/components/App' + +export default { + args: { + data: {} + }, + component: App, + title: 'Connect' +} as Meta + +const Template: Story = () => { + return +} + +export const Studio = Template.bind({}) diff --git a/webview/webpack.config.ts b/webview/webpack.config.ts index febb1f758d..68c6cec109 100644 --- a/webview/webpack.config.ts +++ b/webview/webpack.config.ts @@ -24,6 +24,7 @@ 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'],