From 57702648ac4121a9e74a9bff0165f48a8be825ab Mon Sep 17 00:00:00 2001 From: Sebastian Tiedtke Date: Mon, 10 Jul 2023 16:31:32 -0400 Subject: [PATCH 1/5] Introduce panel --- package.json | 18 +++ src/extension/extension.ts | 8 ++ src/extension/panels/panel.ts | 237 ++++++++++++++++++++++++++++++++++ 3 files changed, 263 insertions(+) create mode 100644 src/extension/panels/panel.ts diff --git a/package.json b/package.json index ae8070c2f..827b442e8 100644 --- a/package.json +++ b/package.json @@ -560,13 +560,31 @@ } } ], + "viewsContainers": { + "activitybar": [ + { + "id": "runme", + "title": "Runme", + "icon": "assets/runme-logo-dark.svg" + } + ] + }, "views": { "explorer": [ { "id": "runme.launcher", + "type": "tree", "name": "Runme Notebooks", "visibility": "collapsed" } + ], + "runme": [ + { + "id": "runme.cloud", + "type": "webview", + "name": "Cloud", + "visibility": "visible" + } ] }, "viewsWelcome": [ diff --git a/src/extension/extension.ts b/src/extension/extension.ts index 202e83beb..c3949f2e6 100644 --- a/src/extension/extension.ts +++ b/src/extension/extension.ts @@ -40,6 +40,7 @@ import { GrpcRunner, IRunner } from './runner' import { CliProvider } from './provider/cli' import * as survey from './survey' import { RunmeCodeLensProvider } from './provider/codelens' +import Panel from './panels/panel' export class RunmeExtension { async initialize(context: ExtensionContext) { @@ -118,6 +119,7 @@ export class RunmeExtension { kernel, serializer, server, + ...this.registerPanels(context), treeViewer, ...surveys, workspace.registerNotebookSerializer(Kernel.type, serializer, { @@ -200,6 +202,12 @@ export class RunmeExtension { await bootFile() } + protected registerPanels(context: ExtensionContext): Disposable[] { + const id: string = 'runme.cloud' + const p = new Panel(context, id) + return [window.registerWebviewViewProvider(id, p), p] + } + static registerCommand(command: string, callback: (...args: any[]) => any, thisArg?: any) { return commands.registerCommand( command, diff --git a/src/extension/panels/panel.ts b/src/extension/panels/panel.ts new file mode 100644 index 000000000..4ed28d906 --- /dev/null +++ b/src/extension/panels/panel.ts @@ -0,0 +1,237 @@ +/* eslint-disable max-len */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + Disposable, + ExtensionContext, + Webview, + WebviewView, + WebviewViewProvider, + authentication, +} from 'vscode' +import { TelemetryViewProvider } from 'vscode-telemetry' +import { Subject, of, catchError, firstValueFrom, combineLatest, from } from 'rxjs' +import { fetch } from 'undici' +import { map, switchMap } from 'rxjs/operators' + +import { AuthenticationProviders } from '../../constants' +import { getRunmeApiUrl } from '../../utils/configuration' + +export type DefaultUx = 'panels' +export interface InitPayload { + panelId: string + appToken: string | undefined + defaultUx: DefaultUx +} + +export class PanelBase extends TelemetryViewProvider implements Disposable { + public static readonly ALLOWED_CMDS = ['login', 'open', 'openConfig', 'openTerminal', 'addNote'] + // public appConfig: AppConfig + + protected readonly appUrl: string = 'http://localhost:3001' + protected readonly defaultUx: DefaultUx = 'panels' + + // protected viewState: ViewState | undefined = undefined + + constructor(protected readonly context: ExtensionContext) { + super() + // this.appConfig = StateManager.getAppConfig() + // this.appConfig.vscode = this.stateMgr.getExtProps() + // this.appUrl = this.appConfig.appUrl + // this.defaultUx = this.appConfig.appDefaultUx + } + + public dispose() {} + + // protected useAppAuthToken(jwtAuthToken: string): Observable { + // const isVerbose = StateManager.getWorkspaceConfig('verbose', false) + // const opts = { jwtAuthToken } + // const url = '/auth/user/app' + // const _withExpiry = () => + // axios.post(url, {}, getAuthOpts(opts)).pipe( + // map((res) => { + // if (res.data?.token) { + // const expiry = Auth.getTokenExpiry(res.data.token) + // if (expiry <= 0) { + // throw new Error('Invalid token in response') + // } + // } else { + // throw new Error('No token in response') + // } + // return res + // }), + // robustRetry(url, { ...opts, method: 'POST' }), + // switchMap((res) => { + // return new Observable((observer) => { + // const expiry = Auth.getTokenExpiry(res.data.token!) + // timer(expiry - Date.now()).subscribe(() => observer.complete()) + // if (isVerbose) { + // const expMinutes = Math.round((expiry - Date.now()) / 6000) / 10 + // console.log(new Date(), `App auth token renewed (expires in ${expMinutes}min)`) + // } + // observer.next(res.data) + // }) + // }) + // ) + + // const tokenFetcher$ = new Subject>() + // return merge(of(_withExpiry()), tokenFetcher$).pipe( + // concatMap((fetcher) => { + // return fetcher.pipe( + // finalize(() => { + // if (isVerbose) { + // console.log(new Date(), 'App auth token expired') + // } + // return tokenFetcher$.next(_withExpiry()) + // }) + // ) + // }) + // ) + // } + + protected async getAppToken() { + const sessionToken = authentication + .getSession(AuthenticationProviders.GitHub, ['user:email'], { + createIfNone: true, + }) + .then((authSession) => authSession.accessToken) + + // todo(sebastian): needs refactor + return fetch(`${getRunmeApiUrl()}/auth/vscode`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + access_token: await sessionToken, + }), + }) + .then((resp) => resp.json()) + .then((vsc) => { + const token = (vsc as any).token + return fetch(`${getRunmeApiUrl()}/auth/user/app`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + }) + }) + .then((resp) => resp.json()) + .then((resp) => { + return (resp as { token: string })?.token + }) + } + + protected hydrateHtml(html: string, payload: InitPayload) { + let content = html + // if (this.viewState) { + // payload = { + // ...payload, + // syncPayload: { + // onView: this.viewState, + // }, + // } + // // reset once used + // this.viewState = undefined + // } + // eslint-disable-next-line quotes + content = html.replace(`'{ "appToken": null }'`, `'${JSON.stringify(payload)}'`) + content = content.replace( + '' + ), + }), + } +}) + +suite('Panel', () => { + const staticHtml = + '' + const contextMock: ExtensionContext = { + extensionUri: Uri.parse('file:///Users/fakeUser/projects/vscode-runme'), + } as any + const view: WebviewView = { webview: { html: '' } } as any + + test('hydrates HTML', () => { + const p = new Panel(contextMock, 'testing') + const hydrated = p.hydrateHtml(staticHtml, { + appToken: 'a.b.c', + ide: 'code', + panelId: 'main', + defaultUx: 'panels', + }) + + expect(hydrated).toContain('') + expect(hydrated).toContain( + '{"appToken":"a.b.c","ide":"code","panelId":"main","defaultUx":"panels"}' + ) + }) + + test('resovles authed', async () => { + const p = new Panel(contextMock, 'testing') + p.getAppToken = vi.fn().mockResolvedValue({ token: 'webview.auth.token' }) + + await p.resolveWebviewTelemetryView(view) + + expect(view.webview.html).toContain('') + expect(view.webview.html).toContain( + '{"ide":"code","panelId":"testing","appToken":"webview.auth.token","defaultUx":"panels"}' + ) + }) + + test('resovles unauthed', async () => { + const p = new Panel(contextMock, 'testing') + p.getAppToken = vi.fn().mockResolvedValue(null) + + await p.resolveWebviewTelemetryView(view) + + expect(view.webview.html).toContain('') + expect(view.webview.html).toContain( + '{"ide":"code","panelId":"testing","appToken":"EMPTY","defaultUx":"panels"}' + ) + }) +})