-
Notifications
You must be signed in to change notification settings - Fork 29
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Enable saving of Studio access token #3235
Changes from all commits
ed5dd88
efdccee
555d6c6
3443e47
158c556
a09bbb3
17fdc2e
1112443
b1eebd9
39beedc
3ca4c8b
8d56106
b603b78
926ca41
41e4087
6709213
c277d93
605763b
a78e1ef
bc9cf3a
72dd056
02a0fc3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -24,6 +24,7 @@ | |
"featurize", | ||
"hardlinks", | ||
"Interactors", | ||
"isat", | ||
"isdir", | ||
"isempty", | ||
"isequal", | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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" | ||
}, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [I] Double check on initial view placement. |
||
{ | ||
"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)", | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<undefined> { | ||
public readonly viewKey = ViewKey.CONNECT | ||
|
||
private readonly secrets: SecretStorage | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [F] This seems like a better API to use instead of saving the token directly into the User's config. |
||
|
||
constructor(context: ExtensionContext, webviewIcon: Resource) { | ||
mattseddon marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we add some kind of message after the token is saved? Feels a little weird to me that I saved my token then everything related to Studio just disappears from my screen 🤔 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes I will add a toast/modal There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is what the flow looks like now: Screen.Recording.2023-02-16.at.4.33.03.pm.movThere was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let me know what you think. We can update in a follow up. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looks good! Makes things more clear on the connection and what you can do now that you're connected! |
||
} | ||
|
||
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 | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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() | ||
}) | ||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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() | ||
) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export const STUDIO_URL = 'https://studio.iterative.ai' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[F] We only show the tree view when there is no Studio token available in the secrets store. It can still be hidden by the user. We also give them the option to remove the token and save another one.