From a21b9aa16f0ec571325b9ab6aa600b54e6e7a4bf Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Fri, 14 Jul 2023 17:02:56 -0500 Subject: [PATCH] Workspace management (#66) --- package.json | 148 ++++++++- resources/explorer.svg | 3 + src/commands/workspaces.ts | 348 ++++++++++++++++++++ src/common/async.ts | 27 ++ src/common/event.ts | 16 +- src/common/utils.ts | 24 ++ src/extension.ts | 32 +- src/local-ssh/ipc/extensionServiceServer.ts | 57 +--- src/publicApi.ts | 70 +++- src/remote.ts | 7 + src/remoteSession.ts | 5 +- src/services/remoteService.ts | 28 +- src/services/sessionService.ts | 10 +- src/ssh/sshDestination.ts | 11 + src/workspaceState.ts | 3 +- src/workspaceView.ts | 74 +++++ src/workspacesExplorerView.ts | 171 ++++++++++ yarn.lock | 6 +- 18 files changed, 983 insertions(+), 57 deletions(-) create mode 100644 resources/explorer.svg create mode 100644 src/commands/workspaces.ts create mode 100644 src/workspaceView.ts create mode 100644 src/workspacesExplorerView.ts diff --git a/package.json b/package.json index d136c419..0b6ccbf1 100644 --- a/package.json +++ b/package.json @@ -108,13 +108,48 @@ }, { "command": "gitpod.installLocalExtensions", - "title": "Gitpod: Install Local Extensions...", + "title": "Install Local Extensions...", + "category": "Gitpod", "enablement": "gitpod.inWorkspace == true" }, { "command": "gitpod.signIn", "category": "Gitpod", "title": "Sign In" + }, + { + "command": "gitpod.workspaces.refresh", + "category": "Gitpod", + "title": "Refresh", + "icon": "$(refresh)" + }, + { + "command": "gitpod.workspaces.connectInNewWindow", + "category": "Gitpod", + "title": "Open in New Window...", + "icon": "$(empty-window)" + }, + { + "command": "gitpod.workspaces.connectInCurrentWindow", + "category": "Gitpod", + "title": "Open...", + "icon": "$(arrow-right)" + }, + { + "command": "gitpod.workspaces.openInBrowser", + "category": "Gitpod", + "title": "Open in Browser..." + }, + { + "command": "gitpod.workspaces.openContext", + "category": "Gitpod", + "title": "Open Context" + }, + { + "command": "gitpod.workspaces.disconnect", + "category": "Gitpod", + "title": "Close Remote Connection", + "icon": "$(debug-disconnect)" } ], "menus": { @@ -124,8 +159,115 @@ "group": "remote_00_gitpod_navigation@01", "when": "gitpod.inWorkspace == true" } + ], + "view/title": [ + { + "command": "gitpod.workspaces.refresh", + "when": "view == gitpod-workspaces", + "group": "navigation" + } + ], + "view/item/context": [ + { + "command": "gitpod.workspaces.connectInCurrentWindow", + "when": "viewItem =~ /^gitpod-workspaces.workspace(?:.running)?$/", + "group": "inline@1" + }, + { + "command": "gitpod.workspaces.disconnect", + "when": "viewItem =~ /^gitpod-workspaces.workspace.+connected$/", + "group": "inline@3" + }, + { + "command": "gitpod.workspaces.connectInCurrentWindow", + "when": "viewItem =~ /^gitpod-workspaces.workspace/", + "group": "navigation@1" + }, + { + "command": "gitpod.workspaces.connectInNewWindow", + "when": "viewItem =~ /^gitpod-workspaces.workspace/", + "group": "navigation@2" + }, + { + "command": "gitpod.workspaces.openInBrowser", + "when": "viewItem =~ /^gitpod-workspaces.workspace/", + "group": "navigation@4" + }, + { + "command": "gitpod.workspaces.openContext", + "when": "viewItem =~ /^gitpod-workspaces.workspace/", + "group": "navigation@5" + } + ], + "commandPalette": [ + { + "command": "gitpod.workspaces.refresh", + "when": "false" + }, + { + "command": "gitpod.workspaces.refresh", + "when": "gitpod.authenticated == true" + }, + { + "command": "gitpod.workspaces.connectInNewWindow", + "when": "gitpod.authenticated == true" + }, + { + "command": "gitpod.workspaces.connectInCurrentWindow", + "when": "gitpod.authenticated == true" + }, + { + "command": "gitpod.workspaces.openInBrowser", + "when": "gitpod.authenticated == true" + }, + { + "command": "gitpod.workspaces.openContext", + "when": "false" + }, + { + "command": "gitpod.workspaces.disconnect", + "when": "false" + } ] - } + }, + "viewsContainers": { + "activitybar": [ + { + "id": "gitpod-view", + "title": "Gitpod", + "icon": "resources/explorer.svg" + } + ] + }, + "views": { + "gitpod-view": [ + { + "id": "gitpod-login", + "name": "Login", + "icon": "$(squirrel)", + "when": "gitpod.authenticated != true" + }, + { + "id": "gitpod-workspaces", + "name": "Workspaces", + "icon": "$(squirrel)", + "when": "gitpod.authenticated == true" + }, + { + "id": "gitpod-workspace", + "name": "Workspace", + "icon": "$(squirrel)", + "when": "false" + } + ] + }, + "viewsWelcome": [ + { + "view": "gitpod-login", + "when": "gitpod.authenticated != true", + "contents": "You have not yet signed in with Gitpod\n[Sign in](command:gitpod.signIn)" + } + ] }, "main": "./out/extension.js", "segmentKey": "YErmvd89wPsrCuGcVnF2XAl846W9WIGl", @@ -201,4 +343,4 @@ "ws": "^8.13.0", "yazl": "^2.5.1" } -} \ No newline at end of file +} diff --git a/resources/explorer.svg b/resources/explorer.svg new file mode 100644 index 00000000..2ada16e2 --- /dev/null +++ b/resources/explorer.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/commands/workspaces.ts b/src/commands/workspaces.ts new file mode 100644 index 00000000..87d0cbb4 --- /dev/null +++ b/src/commands/workspaces.ts @@ -0,0 +1,348 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Gitpod. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { Command } from '../commandManager'; +import { ISessionService } from '../services/sessionService'; +import { WorkspaceData, rawWorkspaceToWorkspaceData } from '../publicApi'; +import { SSHConnectionParams, SSH_DEST_KEY, getLocalSSHDomain } from '../remote'; +import SSHDestination from '../ssh/sshDestination'; +import { IHostService } from '../services/hostService'; +import { WorkspaceState } from '../workspaceState'; +import { ILogService } from '../services/logService'; +import { eventToPromise, raceCancellationError } from '../common/event'; +import { ITelemetryService } from '../common/telemetry'; + +async function showWorkspacesPicker(sessionService: ISessionService, placeHolder: string): Promise { + const pickItemsPromise = sessionService.getAPI().listWorkspaces() + .then(rawWorkspaces => rawWorkspaceToWorkspaceData(rawWorkspaces).map(wsData => { + return { + ...wsData, + label: `${wsData.owner}/${wsData.repo}`, + detail: wsData.id, + }; + })); + + const picked = await vscode.window.showQuickPick(pickItemsPromise, { canPickMany: false, placeHolder }); + return picked; +} + +export class ConnectInNewWindowCommand implements Command { + readonly id = 'gitpod.workspaces.connectInNewWindow'; + + private running = false; + + constructor( + private readonly context: vscode.ExtensionContext, + private readonly sessionService: ISessionService, + private readonly hostService: IHostService, + private readonly telemetryService: ITelemetryService, + private readonly logService: ILogService, + ) { } + + async execute(treeItem?: { id: string }) { + if (this.running) { + return; + } + + try { + this.running = true; + await this.doRun(treeItem); + } finally { + this.running = false; + } + } + + private async doRun(treeItem?: { id: string }) { + let wsData: WorkspaceData | undefined; + if (!treeItem?.id) { + wsData = await showWorkspacesPicker(this.sessionService, 'Select a workspace to connect...'); + } else { + wsData = rawWorkspaceToWorkspaceData(await this.sessionService.getAPI().getWorkspace(treeItem.id)); + } + + if (!wsData) { + return; + } + + this.telemetryService.sendTelemetryEvent('vscode_desktop_view_command', { + name: this.id, + gitpodHost: this.hostService.gitpodHost, + workspaceId: wsData.id, + location: treeItem?.id ? 'view' : 'commandPalette' + }); + + const domain = getLocalSSHDomain(this.hostService.gitpodHost); + const sshHostname = `${wsData.id}.${domain}`; + const sshDest = new SSHDestination(sshHostname, wsData.id); + + // TODO: remove this, should not be needed + await this.context.globalState.update(`${SSH_DEST_KEY}${sshDest.toRemoteSSHString()}`, { workspaceId: wsData.id, gitpodHost: this.hostService.gitpodHost, instanceId: '' } as SSHConnectionParams); + + await vscode.window.withProgress( + { + title: `Starting workspace ${wsData.id}`, + location: vscode.ProgressLocation.Notification, + cancellable: true + }, + async (_, cancelToken) => { + let wsState: WorkspaceState | undefined; + try { + wsState = new WorkspaceState(wsData!.id, this.sessionService, this.logService); + await wsState.initialize(); + + if (cancelToken.isCancellationRequested) { + return; + } + + if (wsState.isWorkspaceStopping) { + // TODO: if stopping tell user to await until stopped to start again + return; + } + + if (wsState.isWorkspaceStopped) { + // Start workspace automatically + await this.sessionService.getAPI().startWorkspace(wsData!.id); + + if (cancelToken.isCancellationRequested) { + return; + } + + await raceCancellationError(eventToPromise(wsState.onWorkspaceRunning), cancelToken); + } + + // TODO: getWorkspace API need to return path to open, for now harcode it + await vscode.commands.executeCommand( + 'vscode.openFolder', + vscode.Uri.parse(`vscode-remote://ssh-remote+${sshDest.toRemoteSSHString()}${wsData!.recentFolders[0] || `/workspace/${wsData!.repo}`}`), + { forceNewWindow: true } + ); + } finally { + wsState?.dispose(); + } + } + ); + } +} + +export class ConnectInCurrentWindowCommand implements Command { + readonly id = 'gitpod.workspaces.connectInCurrentWindow'; + + private running = false; + + constructor( + private readonly context: vscode.ExtensionContext, + private readonly sessionService: ISessionService, + private readonly hostService: IHostService, + private readonly telemetryService: ITelemetryService, + private readonly logService: ILogService, + ) { } + + async execute(treeItem?: { id: string }) { + if (this.running) { + return; + } + + try { + this.running = true; + await this.doRun(treeItem); + } finally { + this.running = false; + } + } + + private async doRun(treeItem?: { id: string }) { + let wsData: WorkspaceData | undefined; + if (!treeItem?.id) { + wsData = await showWorkspacesPicker(this.sessionService, 'Select a workspace to connect...'); + } else { + wsData = rawWorkspaceToWorkspaceData(await this.sessionService.getAPI().getWorkspace(treeItem.id)); + } + + if (!wsData) { + return; + } + + this.telemetryService.sendTelemetryEvent('vscode_desktop_view_command', { + name: this.id, + gitpodHost: this.hostService.gitpodHost, + workspaceId: wsData.id, + location: treeItem?.id ? 'view' : 'commandPalette' + }); + + const domain = getLocalSSHDomain(this.hostService.gitpodHost); + const sshHostname = `${wsData.id}.${domain}`; + const sshDest = new SSHDestination(sshHostname, wsData.id); + + // TODO: remove this, should not be needed + await this.context.globalState.update(`${SSH_DEST_KEY}${sshDest.toRemoteSSHString()}`, { workspaceId: wsData.id, gitpodHost: this.hostService.gitpodHost, instanceId: '' } as SSHConnectionParams); + + await vscode.window.withProgress( + { + title: `Starting workspace ${wsData.id}`, + location: vscode.ProgressLocation.Notification, + cancellable: true + }, + async (_, cancelToken) => { + let wsState: WorkspaceState | undefined; + try { + wsState = new WorkspaceState(wsData!.id, this.sessionService, this.logService); + await wsState.initialize(); + + if (cancelToken.isCancellationRequested) { + return; + } + + if (wsState.isWorkspaceStopping) { + // TODO: if stopping tell user to await until stopped to start again + return; + } + + if (wsState.isWorkspaceStopped) { + // Start workspace automatically + await this.sessionService.getAPI().startWorkspace(wsData!.id); + + if (cancelToken.isCancellationRequested) { + return; + } + + await raceCancellationError(eventToPromise(wsState.onWorkspaceRunning), cancelToken); + } + + // TODO: getWorkspace API need to return path to open, for now harcode it + await vscode.commands.executeCommand( + 'vscode.openFolder', + vscode.Uri.parse(`vscode-remote://ssh-remote+${sshDest.toRemoteSSHString()}${wsData!.recentFolders[0] || `/workspace/${wsData!.repo}`}`), + { forceNewWindow: false } + ); + } finally { + wsState?.dispose(); + } + } + ); + } +} + +export class StopWorkspaceCommand implements Command { + readonly id = 'gitpod.workspaces.stopWorkspace'; + + constructor(private readonly sessionService: ISessionService) { } + + async execute(treeItem?: { id: string }) { + let workspaceId: string | undefined; + if (!treeItem?.id) { + workspaceId = (await showWorkspacesPicker(this.sessionService, 'Select a workspace to stop...'))?.id; + } else { + workspaceId = treeItem.id; + } + + if (workspaceId) { + await this.sessionService.getAPI().stopWorkspace(workspaceId); + } + } +} + +export class StopCurrentWorkspaceCommand implements Command { + readonly id = 'gitpod.workspaces.stopCurrentWorkspace'; + + constructor(private readonly connectionInfo: SSHConnectionParams | undefined, private readonly sessionService: ISessionService) { } + + async execute() { + if (!this.connectionInfo) { + return; + } + + await this.sessionService.getAPI().stopWorkspace(this.connectionInfo.workspaceId); + await vscode.commands.executeCommand('workbench.action.remote.close'); + } +} + +export class OpenInBrowserCommand implements Command { + readonly id = 'gitpod.workspaces.openInBrowser'; + + constructor( + private readonly sessionService: ISessionService, + private readonly hostService: IHostService, + private readonly telemetryService: ITelemetryService, + ) { } + + async execute(treeItem?: { id: string }) { + let wsData: WorkspaceData | undefined; + if (!treeItem?.id) { + wsData = (await showWorkspacesPicker(this.sessionService, 'Select a workspace to connect...')); + } else { + wsData = rawWorkspaceToWorkspaceData(await this.sessionService.getAPI().getWorkspace(treeItem.id)); + } + + if (!wsData) { + return; + } + + this.telemetryService.sendTelemetryEvent('vscode_desktop_view_command', { + name: this.id, + gitpodHost: this.hostService.gitpodHost, + workspaceId: wsData.id, + location: treeItem?.id ? 'gitpodView' : 'commandPalette' + }); + + await vscode.env.openExternal(vscode.Uri.parse(wsData.workspaceUrl)); + } +} + +export class DeleteWorkspaceCommand implements Command { + readonly id = 'gitpod.workspaces.deleteWorkspace'; + + constructor(private readonly sessionService: ISessionService) { } + + async execute(treeItem?: { id: string }) { + let workspaceId: string | undefined; + if (!treeItem?.id) { + workspaceId = (await showWorkspacesPicker(this.sessionService, 'Select a workspace to delete...'))?.id; + } else { + workspaceId = treeItem.id; + } + + if (workspaceId) { + await this.sessionService.getAPI().deleteWorkspace(workspaceId); + } + } +} + +export class OpenWorkspaceContextCommand implements Command { + readonly id = 'gitpod.workspaces.openContext'; + + constructor( + private readonly sessionService: ISessionService, + private readonly hostService: IHostService, + private readonly telemetryService: ITelemetryService, + ) { } + + async execute(treeItem: { id: string }) { + if (!treeItem?.id) { + return; + } + + const wsData = rawWorkspaceToWorkspaceData(await this.sessionService.getAPI().getWorkspace(treeItem.id)); + + this.telemetryService.sendTelemetryEvent('vscode_desktop_view_command', { + name: this.id, + gitpodHost: this.hostService.gitpodHost, + workspaceId: wsData.id, + location: treeItem?.id ? 'gitpodView' : 'commandPalette' + }); + + await vscode.env.openExternal(vscode.Uri.parse(wsData.contextUrl)); + } +} + +export class DisconnectWorkspaceCommand implements Command { + readonly id = 'gitpod.workspaces.disconnect'; + + constructor() { } + + async execute() { + await vscode.commands.executeCommand('workbench.action.remote.close'); + } +} diff --git a/src/common/async.ts b/src/common/async.ts index e421d355..7b5cb6b2 100644 --- a/src/common/async.ts +++ b/src/common/async.ts @@ -46,3 +46,30 @@ export async function retryWithStop(task: (stop: () => void) => Promise, d } throw lastError; } + +export class Barrier { + + private _isOpen: boolean; + private _promise: Promise; + private _completePromise!: (v: boolean) => void; + + constructor() { + this._isOpen = false; + this._promise = new Promise((c, _) => { + this._completePromise = c; + }); + } + + isOpen(): boolean { + return this._isOpen; + } + + open(): void { + this._isOpen = true; + this._completePromise(true); + } + + wait(): Promise { + return this._promise; + } +} diff --git a/src/common/event.ts b/src/common/event.ts index a82ddc66..8174239e 100644 --- a/src/common/event.ts +++ b/src/common/event.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { EventEmitter, Event, Disposable } from 'vscode'; +import { EventEmitter, Event, Disposable, CancellationToken, CancellationError } from 'vscode'; export function filterEvent(event: Event, filter: (e: T) => boolean): Event { return (listener, thisArgs = null, disposables?) => event(e => filter(e) && listener.call(thisArgs, e), null, disposables); @@ -98,3 +98,17 @@ export function promiseFromEvent( export function eventToPromise(event: Event): Promise { return new Promise(resolve => onceEvent(event)(resolve)); } + +/** + * Returns a promise that rejects with an {@CancellationError} as soon as the passed token is cancelled. + * @see {@link raceCancellation} + */ +export function raceCancellationError(promise: Promise, token: CancellationToken): Promise { + return new Promise((resolve, reject) => { + const ref = token.onCancellationRequested(() => { + ref.dispose(); + reject(new CancellationError()); + }); + promise.then(resolve, reject).finally(() => ref.dispose()); + }); +} diff --git a/src/common/utils.ts b/src/common/utils.ts index bde04a16..10170126 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -110,6 +110,30 @@ export function mixin(destination: any, source: any, overwrite: boolean = true): return destination; } +export function groupBy(data: ReadonlyArray, compare: (a: T, b: T) => number): T[][] { + const result: T[][] = []; + let currentGroup: T[] | undefined = undefined; + for (const element of data.slice(0).sort(compare)) { + if (!currentGroup || compare(currentGroup[0], element) !== 0) { + currentGroup = [element]; + result.push(currentGroup); + } else { + currentGroup.push(element); + } + } + return result; +} + +export function stringCompare(a: string, b: string): number { + if (a < b) { + return -1; + } else if (a > b) { + return 1; + } else { + return 0; + } +} + export function getServiceURL(gitpodHost: string): string { return new URL(gitpodHost).toString().replace(/\/$/, ''); } diff --git a/src/extension.ts b/src/extension.ts index 16106b76..d4ffd1e0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -22,6 +22,9 @@ import { SignInCommand } from './commands/account'; import { ExportLogsCommand } from './commands/logs'; import { Configuration } from './configuration'; import { RemoteService } from './services/remoteService'; +import { WorkspacesExplorerView } from './workspacesExplorerView'; +import { ConnectInCurrentWindowCommand, ConnectInNewWindowCommand, DeleteWorkspaceCommand, OpenWorkspaceContextCommand, OpenInBrowserCommand, StopCurrentWorkspaceCommand, StopWorkspaceCommand, DisconnectWorkspaceCommand } from './commands/workspaces'; +import { WorkspaceView } from './workspaceView'; // connect-web uses fetch api, so we need to polyfill it if (!global.fetch) { @@ -104,23 +107,48 @@ export async function activate(context: vscode.ExtensionContext) { } })); + remoteConnectionInfo = getGitpodRemoteWindowConnectionInfo(context); + vscode.commands.executeCommand('setContext', 'gitpod.remoteConnection', !!remoteConnectionInfo); + + const workspacesExplorerView = new WorkspacesExplorerView(context, commandManager, sessionService, hostService); + context.subscriptions.push(workspacesExplorerView); + + if (remoteConnectionInfo) { + const workspaceView = new WorkspaceView(remoteConnectionInfo.connectionInfo.workspaceId, sessionService); + context.subscriptions.push(workspaceView); + } + // Register global commands commandManager.register(new SignInCommand(sessionService)); commandManager.register(new ExportLogsCommand(context.logUri, notificationService, telemetryService, logger, hostService)); + commandManager.register(new ConnectInNewWindowCommand(context, sessionService, hostService, telemetryService, logger)); + commandManager.register(new ConnectInCurrentWindowCommand(context, sessionService, hostService, telemetryService, logger)); + commandManager.register(new StopWorkspaceCommand(sessionService)); + commandManager.register(new StopCurrentWorkspaceCommand(remoteConnectionInfo?.connectionInfo, sessionService)); + commandManager.register(new OpenInBrowserCommand(sessionService, hostService, telemetryService)); + commandManager.register(new DeleteWorkspaceCommand(sessionService)); + commandManager.register(new OpenWorkspaceContextCommand(sessionService, hostService, telemetryService)); + commandManager.register(new DisconnectWorkspaceCommand()); if (!context.globalState.get(FIRST_INSTALL_KEY, false)) { context.globalState.update(FIRST_INSTALL_KEY, true); telemetryService.sendTelemetryEvent('gitpod_desktop_installation', { gitpodHost: hostService.gitpodHost, kind: 'install' }); } - remoteConnectionInfo = getGitpodRemoteWindowConnectionInfo(context); // Because auth provider implementation is in the same extension, we need to wait for it to activate first sessionService.didFirstLoad.then(async () => { if (remoteConnectionInfo) { commandManager.register({ id: 'gitpod.api.autoTunnel', execute: () => remoteConnector.autoTunnelCommand }); - remoteSession = new RemoteSession(remoteConnectionInfo.connectionInfo, context, hostService, sessionService, settingsSync, experiments, logger!, telemetryService!, notificationService); + remoteSession = new RemoteSession(remoteConnectionInfo.connectionInfo, context, remoteService, hostService, sessionService, settingsSync, experiments, logger!, telemetryService!, notificationService); await remoteSession.initialize(); + } else if (sessionService.isSignedIn()) { + remoteService.checkForStoppedWorkspaces(async wsInfo => { + if (!workspacesExplorerView.isVisible()) { + await vscode.commands.executeCommand('gitpod-workspaces.focus'); + await workspacesExplorerView.reveal(wsInfo.workspaceId, { select: true }); + } + }); } }); diff --git a/src/local-ssh/ipc/extensionServiceServer.ts b/src/local-ssh/ipc/extensionServiceServer.ts index 9defb92e..a25c65b0 100644 --- a/src/local-ssh/ipc/extensionServiceServer.ts +++ b/src/local-ssh/ipc/extensionServiceServer.ts @@ -5,16 +5,12 @@ import { ExtensionServiceDefinition, ExtensionServiceImplementation, GetWorkspaceAuthInfoRequest, GetWorkspaceAuthInfoResponse, PingRequest, SendErrorReportRequest, SendLocalSSHUserFlowStatusRequest } from '../../proto/typescript/ipc/v1/ipc'; import { Disposable } from '../../common/dispose'; -import { withServerApi } from '../../internalApi'; -import { Workspace, WorkspaceInstanceStatus_Phase } from '@gitpod/public-api/lib/gitpod/experimental/v1'; -import { WorkspaceInfo, WorkspaceInstancePhase } from '@gitpod/gitpod-protocol'; import { ILogService } from '../../services/logService'; import { ISessionService } from '../../services/sessionService'; import { CallContext, ServerError, Status } from 'nice-grpc-common'; import { IHostService } from '../../services/hostService'; import { Server, createChannel, createClient, createServer } from 'nice-grpc'; import { ITelemetryService, UserFlowTelemetryProperties } from '../../common/telemetry'; -import { ExperimentalSettings } from '../../experiments'; import { Configuration } from '../../configuration'; import { timeout } from '../../common/async'; import { BrowserHeaders } from 'browser-headers'; @@ -26,25 +22,13 @@ import { ParsedKey } from 'ssh2-streams'; import { isPortUsed } from '../../common/ports'; import { WrapError } from '../../common/utils'; import { ConnectError, Code } from '@bufbuild/connect'; +import { rawWorkspaceToWorkspaceData } from '../../publicApi'; function isServiceError(obj: any): obj is ServiceError { // eslint-disable-next-line eqeqeq return obj != null && typeof obj === 'object' && typeof obj.metadata != null && typeof obj.code === 'number' && typeof obj.message === 'string'; } -const phaseMap: Record = { - [WorkspaceInstanceStatus_Phase.CREATING]: 'pending', - [WorkspaceInstanceStatus_Phase.IMAGEBUILD]: 'building', - [WorkspaceInstanceStatus_Phase.INITIALIZING]: 'initializing', - [WorkspaceInstanceStatus_Phase.INTERRUPTED]: 'interrupted', - [WorkspaceInstanceStatus_Phase.PENDING]: 'stopping', - [WorkspaceInstanceStatus_Phase.PREPARING]: 'stopped', - [WorkspaceInstanceStatus_Phase.RUNNING]: 'running', - [WorkspaceInstanceStatus_Phase.STOPPED]: 'stopped', - [WorkspaceInstanceStatus_Phase.STOPPING]: 'stopping', - [WorkspaceInstanceStatus_Phase.UNSPECIFIED]: undefined, -}; - function wrapSupervisorAPIError(callback: () => Promise, opts?: { maxRetries?: number; signal?: AbortSignal }): Promise { const maxRetries = opts?.maxRetries ?? 5; let retries = 0; @@ -76,19 +60,17 @@ class ExtensionServiceImpl implements ExtensionServiceImplementation { private logService: ILogService, private sessionService: ISessionService, private hostService: IHostService, - private experiments: ExperimentalSettings, private telemetryService: ITelemetryService ) { - } - private async getWorkspaceSSHKey(ownerToken: string, workspaceId: string, workspaceHost: string, signal: AbortSignal) { - const workspaceUrl = `https://${workspaceId}.${workspaceHost}`; - const metadata = new BrowserHeaders(); - metadata.append('x-gitpod-owner-token', ownerToken); - const client = new ControlServiceClient(`${workspaceUrl}/_supervisor/v1`, { transport: NodeHttpTransport() }); - + private async getWorkspaceSSHKey(ownerToken: string, workspaceUrl: string, signal: AbortSignal) { + const url = new URL(workspaceUrl); + url.pathname = '/_supervisor/v1'; const privateKey = await wrapSupervisorAPIError(() => new Promise((resolve, reject) => { + const metadata = new BrowserHeaders(); + metadata.append('x-gitpod-owner-token', ownerToken); + const client = new ControlServiceClient(url.toString(), { transport: NodeHttpTransport() }); client.createSSHKeyPair(new CreateSSHKeyPairRequest(), metadata, (err, resp) => { if (err) { return reject(err); @@ -125,23 +107,17 @@ class ExtensionServiceImpl implements ExtensionServiceImplementation { } // TODO(lssh): Get auth info according to `request.gitpodHost` const gitpodHost = this.hostService.gitpodHost; - const usePublicApi = await this.experiments.getUsePublicAPI(gitpodHost); - const [workspace, ownerToken] = await withServerApi(accessToken, gitpodHost, svc => Promise.all([ - usePublicApi ? this.sessionService.getAPI().getWorkspace(actualWorkspaceId, _context.signal) : svc.server.getWorkspace(actualWorkspaceId), - usePublicApi ? this.sessionService.getAPI().getOwnerToken(actualWorkspaceId, _context.signal) : svc.server.getOwnerToken(actualWorkspaceId), - ]), this.logService); - const phase = usePublicApi ? phaseMap[(workspace as Workspace).status?.instance?.status?.phase ?? WorkspaceInstanceStatus_Phase.UNSPECIFIED] : (workspace as WorkspaceInfo).latestInstance?.status.phase; + const rawWorkspace = await this.sessionService.getAPI().getWorkspace(actualWorkspaceId, _context.signal); + const wsData = rawWorkspaceToWorkspaceData(rawWorkspace); - const ideUrl = usePublicApi ? (workspace as Workspace).status?.instance?.status?.url : (workspace as WorkspaceInfo).latestInstance?.ideUrl; - if (!ideUrl) { - throw new ServerError(Status.DATA_LOSS, 'no ide url found'); - } - const url = new URL(ideUrl); + const ownerToken = await this.sessionService.getAPI().getOwnerToken(actualWorkspaceId, _context.signal); + + instanceId = rawWorkspace.status!.instance!.instanceId; + const url = new URL(wsData.workspaceUrl); const workspaceHost = url.host.substring(url.host.indexOf('.') + 1); - instanceId = (usePublicApi ? (workspace as Workspace).status?.instance?.instanceId : (workspace as WorkspaceInfo).latestInstance?.id) as string; - const sshkey = phase === 'running' ? (await this.getWorkspaceSSHKey(ownerToken, workspaceId, workspaceHost, _context.signal)) : ''; + const sshkey = wsData.phase === 'running' ? (await this.getWorkspaceSSHKey(ownerToken, wsData.workspaceUrl, _context.signal)) : ''; return { gitpodHost, @@ -151,7 +127,7 @@ class ExtensionServiceImpl implements ExtensionServiceImplementation { workspaceHost, ownerToken, sshkey, - phase: phase ?? 'unknown', + phase: wsData.phase, }; } catch (e) { let code = Status.INTERNAL; @@ -223,7 +199,6 @@ export class ExtensionServiceServer extends Disposable { private readonly sessionService: ISessionService, private readonly hostService: IHostService, private readonly telemetryService: ITelemetryService, - private experiments: ExperimentalSettings, ) { super(); this.server = this.getServer(); @@ -232,7 +207,7 @@ export class ExtensionServiceServer extends Disposable { private getServer(): Server { const server = createServer(); - const serviceImpl = new ExtensionServiceImpl(this.logService, this.sessionService, this.hostService, this.experiments, this.telemetryService); + const serviceImpl = new ExtensionServiceImpl(this.logService, this.sessionService, this.hostService, this.telemetryService); server.add(ExtensionServiceDefinition, serviceImpl); return server; } diff --git a/src/publicApi.ts b/src/publicApi.ts index ac6f962c..1feb434c 100644 --- a/src/publicApi.ts +++ b/src/publicApi.ts @@ -8,7 +8,7 @@ import { createPromiseClient, Interceptor, PromiseClient, ConnectError, Code } f import { WorkspacesService } from '@gitpod/public-api/lib/gitpod/experimental/v1/workspaces_connectweb'; import { IDEClientService } from '@gitpod/public-api/lib/gitpod/experimental/v1/ide_client_connectweb'; import { UserService } from '@gitpod/public-api/lib/gitpod/experimental/v1/user_connectweb'; -import { Workspace, WorkspaceStatus } from '@gitpod/public-api/lib/gitpod/experimental/v1/workspaces_pb'; +import { Workspace, WorkspaceInstanceStatus_Phase, WorkspaceStatus } from '@gitpod/public-api/lib/gitpod/experimental/v1/workspaces_pb'; import { SSHKey, User } from '@gitpod/public-api/lib/gitpod/experimental/v1/user_pb'; import * as vscode from 'vscode'; import { Disposable } from './common/dispose'; @@ -33,8 +33,11 @@ function isTelemetryEnabled(): boolean { } export interface IGitpodAPI { + listWorkspaces(): Promise; getWorkspace(workspaceId: string, signal?: AbortSignal): Promise; startWorkspace(workspaceId: string): Promise; + stopWorkspace(workspaceId: string): Promise; + deleteWorkspace(workspaceId: string): Promise; getOwnerToken(workspaceId: string, signal?: AbortSignal): Promise; getSSHKeys(): Promise; sendHeartbeat(workspaceId: string): Promise; @@ -92,6 +95,12 @@ export class GitpodPublicApi extends Disposable implements IGitpodAPI { this.ideClientService = createPromiseClient(IDEClientService, transport); } + async listWorkspaces(): Promise { + return this._wrapError(this._workaroundGoAwayBug(async () => { + const response = await this.workspaceService.listWorkspaces({}); + return response.result; + })); + } async getWorkspace(workspaceId: string, signal?: AbortSignal): Promise { return this._wrapError(this._workaroundGoAwayBug(async () => { @@ -107,6 +116,19 @@ export class GitpodPublicApi extends Disposable implements IGitpodAPI { })); } + async stopWorkspace(workspaceId: string): Promise { + return this._wrapError(this._workaroundGoAwayBug(async () => { + const response = await this.workspaceService.stopWorkspace({ workspaceId }); + return response.result!; + })); + } + + async deleteWorkspace(workspaceId: string): Promise { + return this._wrapError(this._workaroundGoAwayBug(async () => { + await this.workspaceService.deleteWorkspace({ workspaceId }); + })); + } + async getOwnerToken(workspaceId: string, signal?: AbortSignal): Promise { return this._wrapError(this._workaroundGoAwayBug(async () => { const response = await this.workspaceService.getOwnerToken({ workspaceId }); @@ -265,3 +287,49 @@ export class GitpodPublicApi extends Disposable implements IGitpodAPI { this.metricsReporter.stopReporting(); } } + +export type WorkspacePhase = 'unspecified' | 'preparing' | 'imagebuild' | 'pending' | 'creating' | 'initializing' | 'running' | 'interrupted' | 'stopping' | 'stopped'; + +export interface WorkspaceData { + provider: string; + owner: string; + repo: string; + id: string; + contextUrl: string; + workspaceUrl: string; + phase: WorkspacePhase; + description: string; + lastUsed: Date; + recentFolders : string[]; +} + +export function rawWorkspaceToWorkspaceData(rawWorkspaces: Workspace): WorkspaceData; +export function rawWorkspaceToWorkspaceData(rawWorkspaces: Workspace[]): WorkspaceData[]; +export function rawWorkspaceToWorkspaceData(rawWorkspaces: Workspace | Workspace[]) { + const toWorkspaceData = (ws: Workspace) => { + const url = new URL(ws.context!.contextUrl); + const provider = url.host.replace(/\..+?$/, ''); // remove '.com', etc + const matches = url.pathname.match(/[^/]+/g)!; // match /owner/repo + const owner = matches[0]; + const repo = matches[1]; + return { + provider, + owner, + repo, + id: ws.workspaceId, + contextUrl: ws.context!.contextUrl, + workspaceUrl: ws.status!.instance!.status!.url, + phase: WorkspaceInstanceStatus_Phase[ws.status!.instance!.status!.phase ?? WorkspaceInstanceStatus_Phase.UNSPECIFIED].toLowerCase() as WorkspacePhase, + description: ws.description, + lastUsed: ws.status!.instance!.createdAt?.toDate(), + recentFolders: ws.status!.instance!.status!.recentFolders + }; + }; + + if (Array.isArray(rawWorkspaces)) { + rawWorkspaces = rawWorkspaces.filter(ws => ws.context?.details.case === 'git'); + return rawWorkspaces.map(toWorkspaceData); + } + + return toWorkspaceData(rawWorkspaces); +} diff --git a/src/remote.ts b/src/remote.ts index 536e2c7c..eddd1b5f 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -13,6 +13,12 @@ export interface SSHConnectionParams { connType?: 'local-app' | 'local-ssh' | 'ssh-gateway'; } +export interface WorkspaceRestartInfo { + workspaceId: string; + gitpodHost: string; + remoteUri: string; +} + export class NoRunningInstanceError extends Error { code = 'NoRunningInstanceError'; constructor(readonly workspaceId: string, readonly phase?: string) { @@ -42,6 +48,7 @@ export class NoLocalSSHSupportError extends Error { } export const SSH_DEST_KEY = 'ssh-dest:'; +export const WORKSPACE_STOPPED_PREFIX = 'stopped_workspace:'; export function getGitpodRemoteWindowConnectionInfo(context: vscode.ExtensionContext): { connectionInfo: SSHConnectionParams; remoteUri: vscode.Uri; sshDestStr: string } | undefined { const remoteUri = vscode.workspace.workspaceFile?.scheme !== 'untitled' diff --git a/src/remoteSession.ts b/src/remoteSession.ts index 57ab2026..0f7359f3 100644 --- a/src/remoteSession.ts +++ b/src/remoteSession.ts @@ -19,6 +19,7 @@ import { ISessionService } from './services/sessionService'; import { IHostService } from './services/hostService'; import { ILogService } from './services/logService'; import { ExtensionServiceServer } from './local-ssh/ipc/extensionServiceServer'; +import { IRemoteService } from './services/remoteService'; export class RemoteSession extends Disposable { @@ -31,6 +32,7 @@ export class RemoteSession extends Disposable { constructor( private connectionInfo: SSHConnectionParams, private readonly context: vscode.ExtensionContext, + private readonly remoteService: IRemoteService, private readonly hostService: IHostService, private readonly sessionService: ISessionService, private readonly settingsSync: SettingsSync, @@ -65,7 +67,7 @@ export class RemoteSession extends Disposable { try { const useLocalSSH = await this.experiments.getUseLocalSSHProxy(); if (useLocalSSH) { - this.extensionServiceServer = new ExtensionServiceServer(this.logService, this.sessionService, this.hostService, this.telemetryService, this.experiments); + this.extensionServiceServer = new ExtensionServiceServer(this.logService, this.sessionService, this.hostService, this.telemetryService); } this.usePublicApi = await this.experiments.getUsePublicAPI(this.connectionInfo.gitpodHost); @@ -80,6 +82,7 @@ export class RemoteSession extends Disposable { } this._register(this.workspaceState.onWorkspaceWillStop(async () => { + await this.remoteService.saveRestartInfo(); vscode.commands.executeCommand('workbench.action.remote.close'); })); instanceId = this.workspaceState.instanceId; diff --git a/src/services/remoteService.ts b/src/services/remoteService.ts index 117d7242..1485ebd9 100644 --- a/src/services/remoteService.ts +++ b/src/services/remoteService.ts @@ -12,7 +12,7 @@ import { Configuration } from '../configuration'; import { IHostService } from './hostService'; import SSHConfiguration from '../ssh/sshConfig'; import { isWindows } from '../common/platform'; -import { getLocalSSHDomain } from '../remote'; +import { WORKSPACE_STOPPED_PREFIX, WorkspaceRestartInfo, getGitpodRemoteWindowConnectionInfo, getLocalSSHDomain } from '../remote'; import { ITelemetryService, UserFlowTelemetryProperties } from '../common/telemetry'; import { ISessionService } from './sessionService'; import { WrapError } from '../common/utils'; @@ -24,6 +24,8 @@ export interface IRemoteService { setupSSHProxy: () => Promise; extensionServerReady: () => Promise; + saveRestartInfo: () => Promise; + checkForStoppedWorkspaces: (cb: (info: WorkspaceRestartInfo) => Promise) => Promise; } type FailedToInitializeCode = 'Unknown' | 'LockFailed' | string; @@ -178,6 +180,30 @@ export class RemoteService extends Disposable implements IRemoteService { return destUri.fsPath; } + async saveRestartInfo() { + const connInfo = getGitpodRemoteWindowConnectionInfo(this.context); + if (!connInfo) { + return; + } + + await this.context.globalState.update(`${WORKSPACE_STOPPED_PREFIX}${connInfo.sshDestStr}`, { workspaceId: connInfo.connectionInfo.workspaceId, gitpodHost: connInfo.connectionInfo.gitpodHost, remoteUri: connInfo.remoteUri.toString() } as WorkspaceRestartInfo); + } + + async checkForStoppedWorkspaces(cb: (info: WorkspaceRestartInfo) => Promise) { + const keys = this.context.globalState.keys(); + const stopped_ws_keys = keys.filter(k => k.startsWith(WORKSPACE_STOPPED_PREFIX)); + for (const k of stopped_ws_keys) { + const ws = this.context.globalState.get(k)!; + if (new URL(this.hostService.gitpodHost).host === new URL(ws.gitpodHost).host) { + try { + await cb(ws); + } catch { + } + } + await this.context.globalState.update(k, undefined); + } + } + private async withLock(path: string, cb: () => Promise) { let release: () => Promise; try { diff --git a/src/services/sessionService.ts b/src/services/sessionService.ts index 2a08418a..224505b6 100644 --- a/src/services/sessionService.ts +++ b/src/services/sessionService.ts @@ -32,11 +32,15 @@ export interface ISessionService { } const sessionScopes = [ + 'function:getWorkspaces', 'function:getWorkspace', - 'function:getOwnerToken', - 'function:getLoggedInUser', + 'function:startWorkspace', + 'function:stopWorkspace', + 'function:deleteWorkspace', 'function:getSSHPublicKeys', 'function:sendHeartBeat', + 'function:getOwnerToken', + 'function:getLoggedInUser', 'resource:default' ]; @@ -64,6 +68,7 @@ export class SessionService extends Disposable implements ISessionService { this._register(vscode.authentication.onDidChangeSessions(e => this.handleOnDidChangeSessions(e))); this.firstLoadPromise = this.tryLoadSession(false); + this.firstLoadPromise.then(() => vscode.commands.executeCommand('setContext', 'gitpod.authenticated', this.isSignedIn())); } private async handleOnDidChangeSessions(e: vscode.AuthenticationSessionsChangeEvent) { @@ -73,6 +78,7 @@ export class SessionService extends Disposable implements ISessionService { const oldSession = this.session; this.session = undefined as vscode.AuthenticationSession | undefined; await this.tryLoadSession(false); + vscode.commands.executeCommand('setContext', 'gitpod.authenticated', this.isSignedIn()); // host changed, sign out, sign in const didChange = oldSession?.id !== this.session?.id; if (didChange) { diff --git a/src/ssh/sshDestination.ts b/src/ssh/sshDestination.ts index bc718095..0bd8f1a8 100644 --- a/src/ssh/sshDestination.ts +++ b/src/ssh/sshDestination.ts @@ -56,4 +56,15 @@ export default class SSHDestination { } return Buffer.from(JSON.stringify(obj), 'utf8').toString('hex'); } + + fromRemoteSSHString(encoded: string) { + try { + const data = JSON.parse(Buffer.from(encoded, 'hex').toString()); + return new SSHDestination(data.hostName, data.user, data.port); + } catch { + } + + // If above fails then it's the plain host + return new SSHDestination(encoded); + } } diff --git a/src/workspaceState.ts b/src/workspaceState.ts index e32bf7de..f82dde1e 100644 --- a/src/workspaceState.ts +++ b/src/workspaceState.ts @@ -9,8 +9,7 @@ import { Disposable } from './common/dispose'; import { ISessionService } from './services/sessionService'; import { ILogService } from './services/logService'; import { filterEvent, onceEvent } from './common/event'; - -export type WorkspacePhase = 'unspecified' | 'preparing' | 'imagebuild' | 'pending' | 'creating' | 'initializing' | 'running' | 'interrupted' | 'stopping' | 'stopped'; +import { WorkspacePhase } from './publicApi'; export class WorkspaceState extends Disposable { private workspaceState: WorkspaceStatus | undefined; diff --git a/src/workspaceView.ts b/src/workspaceView.ts new file mode 100644 index 00000000..aaf8c43a --- /dev/null +++ b/src/workspaceView.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Gitpod. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { Disposable } from './common/dispose'; +import { ISessionService } from './services/sessionService'; +import { rawWorkspaceToWorkspaceData } from './publicApi'; + +class RepoTreeItem { + constructor( + public readonly owner: string, + public readonly repo: string, + public readonly description: string, + ) { + } +} + +class WorkspaceIdTreeItem { + constructor( + public readonly id: string, + ) { + } +} + +type DataTreeItem = RepoTreeItem | WorkspaceIdTreeItem; + +export class WorkspaceView extends Disposable implements vscode.TreeDataProvider { + + private readonly _onDidChangeTreeData = this._register(new vscode.EventEmitter()); + public readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + constructor( + private readonly workspaceId: string, + private readonly sessionService: ISessionService, + ) { + super(); + + this._register(vscode.window.createTreeView('gitpod-workspace', { treeDataProvider: this })); + } + + getTreeItem(element: DataTreeItem): vscode.TreeItem { + if (element instanceof RepoTreeItem) { + const treeItem = new vscode.TreeItem(element.description); + treeItem.collapsibleState = vscode.TreeItemCollapsibleState.None; + treeItem.iconPath = new vscode.ThemeIcon('repo'); + treeItem.contextValue = 'gitpod-workspace.repo'; + return treeItem; + } + + const treeItem = new vscode.TreeItem(element.id); + treeItem.collapsibleState = vscode.TreeItemCollapsibleState.None; + treeItem.iconPath = new vscode.ThemeIcon('tag'); + treeItem.contextValue = 'gitpod-workspace.id'; + return treeItem; + } + + async getChildren(element?: DataTreeItem): Promise { + if (!element) { + let rawWorkspace = await this.sessionService.getAPI().getWorkspace(this.workspaceId); + const workspace = rawWorkspaceToWorkspaceData(rawWorkspace); + return [ + new RepoTreeItem(workspace.owner, workspace.repo, workspace.description), + new WorkspaceIdTreeItem(workspace.id), + ]; + } + return []; + } + + // private refresh() { + // this._onDidChangeTreeData.fire(); + // } +} diff --git a/src/workspacesExplorerView.ts b/src/workspacesExplorerView.ts new file mode 100644 index 00000000..70f18f0a --- /dev/null +++ b/src/workspacesExplorerView.ts @@ -0,0 +1,171 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Gitpod. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { Disposable } from './common/dispose'; +import { ISessionService } from './services/sessionService'; +import { CommandManager } from './commandManager'; +import { rawWorkspaceToWorkspaceData } from './publicApi'; +import { IHostService } from './services/hostService'; +import { getGitpodRemoteWindowConnectionInfo } from './remote'; +import { Barrier } from './common/async'; + +class RepoOwnerTreeItem { + constructor( + public readonly owner: string, + public readonly provider: string, + public readonly workspaces: WorkspaceTreeItem[], + ) { + workspaces.forEach(ws => ws.setParent(this)); + } +} + +class WorkspaceTreeItem { + private _parent!: RepoOwnerTreeItem; + + constructor( + public readonly provider: string, + public readonly owner: string, + public readonly repo: string, + public readonly id: string, + public readonly contextUrl: string, + public readonly isRunning: boolean, + public readonly description: string, + public readonly lastUsed: Date + ) { + } + + setParent(parent: RepoOwnerTreeItem) { this._parent = parent; } + getParent() { return this._parent; } + + getLastUsedPretty(): string { + const millisecondsPerSecond = 1000; + const millisecondsPerMinute = 60 * millisecondsPerSecond; + const millisecondsPerHour = 60 * millisecondsPerMinute; + const millisecondsPerDay = 24 * millisecondsPerHour; + + const diff = new Date(new Date().getTime() - this.lastUsed.getTime()); + const days = Math.trunc(diff.getTime() / millisecondsPerDay); + if (days > 0) { + return `${days} day${days > 1 ? 's' : ''}`; + } + const hours = Math.trunc(diff.getTime() / millisecondsPerHour); + if (hours > 0) { + return `${hours} hour${hours > 1 ? 's' : ''}`; + } + const minutes = Math.trunc(diff.getTime() / millisecondsPerMinute); + if (minutes > 0) { + return `${minutes} minute${minutes > 1 ? 's' : ''}`; + } + const seconds = Math.trunc(diff.getTime() / millisecondsPerSecond); + return `${seconds} second${seconds > 1 ? 's' : ''}`; + } +} + +type DataTreeItem = RepoOwnerTreeItem | WorkspaceTreeItem; + +export class WorkspacesExplorerView extends Disposable implements vscode.TreeDataProvider { + + private readonly _onDidChangeTreeData = this._register(new vscode.EventEmitter()); + public readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + private workspaces: WorkspaceTreeItem[] = []; + private connectedWorkspaceId: string | undefined; + + private treeView: vscode.TreeView; + + private workspacesBarrier = new Barrier(); + + constructor( + readonly context: vscode.ExtensionContext, + readonly commandManager: CommandManager, + private readonly sessionService: ISessionService, + private readonly hostService: IHostService, + ) { + super(); + + this.treeView = this._register(vscode.window.createTreeView('gitpod-workspaces', { treeDataProvider: this })); + + commandManager.register({ id: 'gitpod.workspaces.refresh', execute: () => this.refresh() }); + + this._register(this.hostService.onDidChangeHost(() => this.refresh())); + + this.connectedWorkspaceId = getGitpodRemoteWindowConnectionInfo(context)?.connectionInfo.workspaceId; + } + + getTreeItem(element: DataTreeItem): vscode.TreeItem { + if (element instanceof RepoOwnerTreeItem) { + const treeItem = new vscode.TreeItem(`${element.provider}/${element.owner}`); + treeItem.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; + treeItem.contextValue = 'gitpod-workspaces.repo-owner'; + return treeItem; + } + + const treeItem = new vscode.TreeItem(element.description); + treeItem.description = !element.isRunning ? `${element.getLastUsedPretty()} ago` : ''; + treeItem.collapsibleState = vscode.TreeItemCollapsibleState.None; + treeItem.iconPath = new vscode.ThemeIcon(element.isRunning ? 'vm-running' : 'vm-outline'); + treeItem.contextValue = 'gitpod-workspaces.workspace' + (element.isRunning ? '.running' : '') + (this.connectedWorkspaceId === element.id ? '.connected' : ''); + treeItem.tooltip = new vscode.MarkdownString(`$(repo) ${element.description}\n\n $(tag) ${element.id}\n\n $(link-external) [${element.contextUrl}](${element.contextUrl})\n\n $(clock) Last used ${element.getLastUsedPretty()} ago`, true); + return treeItem; + } + + async getChildren(element?: DataTreeItem): Promise { + if (!element) { + let rawWorkspaces = await this.sessionService.getAPI().listWorkspaces(); + this.workspaces = rawWorkspaceToWorkspaceData(rawWorkspaces).map(ws => { + return new WorkspaceTreeItem( + ws.provider, + ws.owner, + ws.repo, + ws.id, + ws.contextUrl, + ws.phase === 'running', + ws.description, + ws.lastUsed + ); + }); + if (this.connectedWorkspaceId) { + const element = this.workspaces.find(w => w.id === this.connectedWorkspaceId); + const rest = this.workspaces.filter(w => w.id !== this.connectedWorkspaceId); + if (element) { + this.workspaces = [element, ...rest]; + } + } + + this.workspacesBarrier.open(); + + return this.workspaces; + } + if (element instanceof RepoOwnerTreeItem) { + return element.workspaces; + } + return []; + } + + getParent(element: DataTreeItem): vscode.ProviderResult { + if (element instanceof WorkspaceTreeItem) { + return; + } + + return; + } + + private refresh() { + this._onDidChangeTreeData.fire(); + } + + async reveal(workspaceId: string, options?: { select?: boolean; focus?: boolean; }) { + await this.workspacesBarrier.wait(); + const element = this.workspaces.find(w => w.id === workspaceId); + if (element) { + return this.treeView.reveal(element, options); + } + } + + isVisible() { + return this.treeView.visible + } +} diff --git a/yarn.lock b/yarn.lock index c152b1af..54a2d4db 100644 --- a/yarn.lock +++ b/yarn.lock @@ -143,9 +143,9 @@ google-protobuf "^3.19.1" "@gitpod/public-api@main-gha": - version "0.1.5-main-gha.11049" - resolved "https://registry.yarnpkg.com/@gitpod/public-api/-/public-api-0.1.5-main-gha.11049.tgz#03b2efc3fae449f1fb598303d112b27780a5a5b7" - integrity sha512-MlWod/spJgbMTiLPHFUZj8EJEXNwt8zWF8+tWyB/8qx5YcZX8HHpQ5PdbQQM5+PWEdi6EhgnZsMNFKD3i458/w== + version "0.1.5-main-gha.12864" + resolved "https://registry.yarnpkg.com/@gitpod/public-api/-/public-api-0.1.5-main-gha.12864.tgz#2311dbe505e3a122c5a32c6748b4a9846e5d0dc9" + integrity sha512-JpVdYZCAjIm1Nnf1Mwy6Ft0yamueSFuh9KOiLT+W/k2gRrE1sH+IfezsrsNe5S3SiLqiOOz4EIR54l49WKjY2g== dependencies: "@bufbuild/connect-web" "^0.2.1" "@bufbuild/protobuf" "^0.1.1"