* Adds workspaces view Adds skeleton UX outline with hardcoded sample Pull workspaces from api (hacky version) Barebones workspace service, api support to get cloud workspaces Fixes node/api interaction Makes virtual repo nodes for repos using remote url Clean up formatting, rename types, add path provider skeleton Adds workspaces local provider, loads local workspaces, uses saved repo paths Add local path provider, use to resolve paths Adds some handling of workspace repo node types - Adds context of workspace to repo node - Adds missing repo node for when the repo isn't located - Adds green decorator for when the repo is in the current workspace (hacky) Adds locate command for workspace repos Use correct refresh trigger on repo node path change Adds other workspaces api functions, splits calls for workspaces and their repos Adds ux for creating/deleting cloud workspaces Adds UX for adding and removing repos from cloud workspaces Moves locate repo logic to service Makes top-level refresh more functional Fixes a few broken calls Adds sub state responsiveness, UX for plus feature, empty workspace msg Fixes bad request formats, saves on calls to api Adds UX to include all open supported repos in new workspace Pass workspace id down repository node hierarchy Prevents id conflicts when two or more workspaces have the same repo Cleanup More informative tooltip, identify shared workspaces from cloud Splits out web and local path providers Adds more error logging and handling Fixes path provider naming Send workspace fetch info to view, Fixes path formatting Add commands to context menu of items Adds the ability to open workspace as a code workspace file Fixes redundant api calls and bad paths, adds logging for getRepos Preserves settings if overwriting existing code-workspace * Refines menus/toolbars * Adds sign in action to message node * Renames & moves Workspaces to GitLens container * Renames command for opening as vscode workspace * Collapses repo nodes in workspaces by default * Removes unnecessary messaging for missing local workspaces * Disables virtual repo support for now * Fixes "open as vs code workspace" appearing on missing repo nodes * Adds "open repo in new/current window" to workspace repos * Adds "add repo to workspace" to workspace repo context menu * Saves workspace id in settings when converting to code-workspace * Updates namespace of settings key * Adds option to locate all repos in workspace using parent path * Adds current window node and "convert to gk workspace" action * Fixes issue with single repo locate * Improves missing repo nodes, removes deletion from inline * Hides inline items which should not appear without plus account * wip * wip2 * wip 3 * Fixes issue with adding repos to created workspace * Moves cache reset on sub change to service * Fixes bug with legacy local workspaces mapping filepath --------- Co-authored-by: Eric Amodio <eamodio@gmail.com>main
@ -0,0 +1,21 @@ | |||
import type { Disposable } from 'vscode'; | |||
import type { Container } from '../../../container'; | |||
import type { RepositoryPathMappingProvider } from '../../../pathMapping/repositoryPathMappingProvider'; | |||
export class RepositoryWebPathMappingProvider implements RepositoryPathMappingProvider, Disposable { | |||
constructor(private readonly _container: Container) {} | |||
dispose() {} | |||
async getLocalRepoPaths(_options: { | |||
remoteUrl?: string; | |||
repoInfo?: { provider: string; owner: string; repoName: string }; | |||
}): Promise<string[]> { | |||
return []; | |||
} | |||
async writeLocalRepoPath( | |||
_options: { remoteUrl?: string; repoInfo?: { provider: string; owner: string; repoName: string } }, | |||
_localPath: string, | |||
): Promise<void> {} | |||
} |
@ -0,0 +1,27 @@ | |||
import { Uri } from 'vscode'; | |||
import type { LocalWorkspaceFileData } from '../../../plus/workspaces/models'; | |||
import type { WorkspacesPathMappingProvider } from '../../../plus/workspaces/workspacesPathMappingProvider'; | |||
export class WorkspacesWebPathMappingProvider implements WorkspacesPathMappingProvider { | |||
async getCloudWorkspaceRepoPath(_cloudWorkspaceId: string, _repoId: string): Promise<string | undefined> { | |||
return undefined; | |||
} | |||
async writeCloudWorkspaceDiskPathToMap( | |||
_cloudWorkspaceId: string, | |||
_repoId: string, | |||
_repoLocalPath: string, | |||
): Promise<void> {} | |||
async getLocalWorkspaceData(): Promise<LocalWorkspaceFileData> { | |||
return { workspaces: {} }; | |||
} | |||
async writeCodeWorkspaceFile( | |||
_uri: Uri, | |||
_workspaceRepoFilePaths: string[], | |||
_options?: { workspaceId?: string }, | |||
): Promise<boolean> { | |||
return false; | |||
} | |||
} |
@ -0,0 +1,111 @@ | |||
import type { Disposable } from 'vscode'; | |||
import { workspace } from 'vscode'; | |||
import type { Container } from '../../../container'; | |||
import type { LocalRepoDataMap } from '../../../pathMapping/models'; | |||
import type { RepositoryPathMappingProvider } from '../../../pathMapping/repositoryPathMappingProvider'; | |||
import { Logger } from '../../../system/logger'; | |||
import { | |||
acquireSharedFolderWriteLock, | |||
getSharedRepositoryMappingFileUri, | |||
releaseSharedFolderWriteLock, | |||
} from './sharedGKDataFolder'; | |||
export class RepositoryLocalPathMappingProvider implements RepositoryPathMappingProvider, Disposable { | |||
constructor(private readonly container: Container) {} | |||
dispose() {} | |||
private _localRepoDataMap: LocalRepoDataMap | undefined = undefined; | |||
private async ensureLocalRepoDataMap() { | |||
if (this._localRepoDataMap == null) { | |||
await this.loadLocalRepoDataMap(); | |||
} | |||
} | |||
private async getLocalRepoDataMap(): Promise<LocalRepoDataMap> { | |||
await this.ensureLocalRepoDataMap(); | |||
return this._localRepoDataMap ?? {}; | |||
} | |||
async getLocalRepoPaths(options: { | |||
remoteUrl?: string; | |||
repoInfo?: { provider: string; owner: string; repoName: string }; | |||
}): Promise<string[]> { | |||
const paths: string[] = []; | |||
if (options.remoteUrl != null) { | |||
const remoteUrlPaths = await this._getLocalRepoPaths(options.remoteUrl); | |||
if (remoteUrlPaths != null) { | |||
paths.push(...remoteUrlPaths); | |||
} | |||
} | |||
if (options.repoInfo != null) { | |||
const { provider, owner, repoName } = options.repoInfo; | |||
const repoInfoPaths = await this._getLocalRepoPaths(`${provider}/${owner}/${repoName}`); | |||
if (repoInfoPaths != null) { | |||
paths.push(...repoInfoPaths); | |||
} | |||
} | |||
return paths; | |||
} | |||
private async _getLocalRepoPaths(key: string): Promise<string[] | undefined> { | |||
const localRepoDataMap = await this.getLocalRepoDataMap(); | |||
return localRepoDataMap[key]?.paths; | |||
} | |||
private async loadLocalRepoDataMap() { | |||
const localFileUri = getSharedRepositoryMappingFileUri(); | |||
try { | |||
const data = await workspace.fs.readFile(localFileUri); | |||
this._localRepoDataMap = (JSON.parse(data.toString()) ?? {}) as LocalRepoDataMap; | |||
} catch (error) { | |||
Logger.error(error, 'loadLocalRepoDataMap'); | |||
} | |||
} | |||
async writeLocalRepoPath( | |||
options: { remoteUrl?: string; repoInfo?: { provider: string; owner: string; repoName: string } }, | |||
localPath: string, | |||
): Promise<void> { | |||
if (options.remoteUrl != null) { | |||
await this._writeLocalRepoPath(options.remoteUrl, localPath); | |||
} | |||
if ( | |||
options.repoInfo?.provider != null && | |||
options.repoInfo?.owner != null && | |||
options.repoInfo?.repoName != null | |||
) { | |||
const { provider, owner, repoName } = options.repoInfo; | |||
const key = `${provider}/${owner}/${repoName}`; | |||
await this._writeLocalRepoPath(key, localPath); | |||
} | |||
} | |||
private async _writeLocalRepoPath(key: string, localPath: string): Promise<void> { | |||
if (!(await acquireSharedFolderWriteLock())) { | |||
return; | |||
} | |||
await this.loadLocalRepoDataMap(); | |||
if (this._localRepoDataMap == null) { | |||
this._localRepoDataMap = {}; | |||
} | |||
if (this._localRepoDataMap[key] == null || this._localRepoDataMap[key].paths == null) { | |||
this._localRepoDataMap[key] = { paths: [localPath] }; | |||
} else if (!this._localRepoDataMap[key].paths.includes(localPath)) { | |||
this._localRepoDataMap[key].paths.push(localPath); | |||
} | |||
const localFileUri = getSharedRepositoryMappingFileUri(); | |||
const outputData = new Uint8Array(Buffer.from(JSON.stringify(this._localRepoDataMap))); | |||
try { | |||
await workspace.fs.writeFile(localFileUri, outputData); | |||
} catch (error) { | |||
Logger.error(error, 'writeLocalRepoPath'); | |||
} | |||
await releaseSharedFolderWriteLock(); | |||
} | |||
} |
@ -0,0 +1,80 @@ | |||
import os from 'os'; | |||
import path from 'path'; | |||
import { Uri, workspace } from 'vscode'; | |||
import { Logger } from '../../../system/logger'; | |||
import { wait } from '../../../system/promise'; | |||
import { getPlatform } from '../platform'; | |||
export const sharedGKDataFolder = '.gk'; | |||
export async function acquireSharedFolderWriteLock(): Promise<boolean> { | |||
const lockFileUri = getSharedLockFileUri(); | |||
let stat; | |||
while (true) { | |||
try { | |||
stat = await workspace.fs.stat(lockFileUri); | |||
} catch { | |||
// File does not exist, so we can safely create it | |||
break; | |||
} | |||
const currentTime = new Date().getTime(); | |||
if (currentTime - stat.ctime > 30000) { | |||
// File exists, but the timestamp is older than 30 seconds, so we can safely remove it | |||
break; | |||
} | |||
// File exists, and the timestamp is less than 30 seconds old, so we need to wait for it to be removed | |||
await wait(100); | |||
} | |||
try { | |||
// write the lockfile to the shared data folder | |||
await workspace.fs.writeFile(lockFileUri, new Uint8Array(0)); | |||
} catch (error) { | |||
Logger.error(error, 'acquireSharedFolderWriteLock'); | |||
return false; | |||
} | |||
return true; | |||
} | |||
export async function releaseSharedFolderWriteLock(): Promise<boolean> { | |||
try { | |||
const lockFileUri = getSharedLockFileUri(); | |||
await workspace.fs.delete(lockFileUri); | |||
} catch (error) { | |||
Logger.error(error, 'releaseSharedFolderWriteLock'); | |||
return false; | |||
} | |||
return true; | |||
} | |||
function getSharedLockFileUri() { | |||
return Uri.file(path.join(os.homedir(), sharedGKDataFolder, 'lockfile')); | |||
} | |||
export function getSharedRepositoryMappingFileUri() { | |||
return Uri.file(path.join(os.homedir(), sharedGKDataFolder, 'repoMapping.json')); | |||
} | |||
export function getSharedCloudWorkspaceMappingFileUri() { | |||
return Uri.file(path.join(os.homedir(), sharedGKDataFolder, 'cloudWorkspaces.json')); | |||
} | |||
export function getSharedLocalWorkspaceMappingFileUri() { | |||
return Uri.file(path.join(os.homedir(), sharedGKDataFolder, 'localWorkspaces.json')); | |||
} | |||
export function getSharedLegacyLocalWorkspaceMappingFileUri() { | |||
return Uri.file( | |||
path.join( | |||
os.homedir(), | |||
`${getPlatform() === 'windows' ? '/AppData/Roaming/' : ''}.gitkraken`, | |||
'workspaces', | |||
'workspaces.json', | |||
), | |||
); | |||
} |
@ -0,0 +1,131 @@ | |||
import type { Uri } from 'vscode'; | |||
import { workspace } from 'vscode'; | |||
import type { | |||
CloudWorkspacesPathMap, | |||
CodeWorkspaceFileContents, | |||
LocalWorkspaceFileData, | |||
} from '../../../plus/workspaces/models'; | |||
import type { WorkspacesPathMappingProvider } from '../../../plus/workspaces/workspacesPathMappingProvider'; | |||
import { Logger } from '../../../system/logger'; | |||
import { | |||
acquireSharedFolderWriteLock, | |||
getSharedCloudWorkspaceMappingFileUri, | |||
getSharedLegacyLocalWorkspaceMappingFileUri, | |||
getSharedLocalWorkspaceMappingFileUri, | |||
releaseSharedFolderWriteLock, | |||
} from './sharedGKDataFolder'; | |||
export class WorkspacesLocalPathMappingProvider implements WorkspacesPathMappingProvider { | |||
private _cloudWorkspaceRepoPathMap: CloudWorkspacesPathMap | undefined = undefined; | |||
private async ensureCloudWorkspaceRepoPathMap(): Promise<void> { | |||
if (this._cloudWorkspaceRepoPathMap == null) { | |||
await this.loadCloudWorkspaceRepoPathMap(); | |||
} | |||
} | |||
private async getCloudWorkspaceRepoPathMap(): Promise<CloudWorkspacesPathMap> { | |||
await this.ensureCloudWorkspaceRepoPathMap(); | |||
return this._cloudWorkspaceRepoPathMap ?? {}; | |||
} | |||
private async loadCloudWorkspaceRepoPathMap(): Promise<void> { | |||
const localFileUri = getSharedCloudWorkspaceMappingFileUri(); | |||
try { | |||
const data = await workspace.fs.readFile(localFileUri); | |||
this._cloudWorkspaceRepoPathMap = (JSON.parse(data.toString())?.workspaces ?? {}) as CloudWorkspacesPathMap; | |||
} catch (error) { | |||
Logger.error(error, 'loadCloudWorkspaceRepoPathMap'); | |||
} | |||
} | |||
async getCloudWorkspaceRepoPath(cloudWorkspaceId: string, repoId: string): Promise<string | undefined> { | |||
const cloudWorkspaceRepoPathMap = await this.getCloudWorkspaceRepoPathMap(); | |||
return cloudWorkspaceRepoPathMap[cloudWorkspaceId]?.repoPaths[repoId]; | |||
} | |||
async writeCloudWorkspaceDiskPathToMap( | |||
cloudWorkspaceId: string, | |||
repoId: string, | |||
repoLocalPath: string, | |||
): Promise<void> { | |||
if (!(await acquireSharedFolderWriteLock())) { | |||
return; | |||
} | |||
await this.loadCloudWorkspaceRepoPathMap(); | |||
if (this._cloudWorkspaceRepoPathMap == null) { | |||
this._cloudWorkspaceRepoPathMap = {}; | |||
} | |||
if (this._cloudWorkspaceRepoPathMap[cloudWorkspaceId] == null) { | |||
this._cloudWorkspaceRepoPathMap[cloudWorkspaceId] = { repoPaths: {} }; | |||
} | |||
this._cloudWorkspaceRepoPathMap[cloudWorkspaceId].repoPaths[repoId] = repoLocalPath; | |||
const localFileUri = getSharedCloudWorkspaceMappingFileUri(); | |||
const outputData = new Uint8Array(Buffer.from(JSON.stringify({ workspaces: this._cloudWorkspaceRepoPathMap }))); | |||
try { | |||
await workspace.fs.writeFile(localFileUri, outputData); | |||
} catch (error) { | |||
Logger.error(error, 'writeCloudWorkspaceDiskPathToMap'); | |||
} | |||
await releaseSharedFolderWriteLock(); | |||
} | |||
// TODO@ramint: May want a file watcher on this file down the line | |||
async getLocalWorkspaceData(): Promise<LocalWorkspaceFileData> { | |||
// Read from file at path defined in the constant localWorkspaceDataFilePath | |||
// If file does not exist, create it and return an empty object | |||
let localFileUri; | |||
let data; | |||
try { | |||
localFileUri = getSharedLocalWorkspaceMappingFileUri(); | |||
data = await workspace.fs.readFile(localFileUri); | |||
return JSON.parse(data.toString()) as LocalWorkspaceFileData; | |||
} catch (error) { | |||
// Fall back to using legacy location for file | |||
try { | |||
localFileUri = getSharedLegacyLocalWorkspaceMappingFileUri(); | |||
data = await workspace.fs.readFile(localFileUri); | |||
return JSON.parse(data.toString()) as LocalWorkspaceFileData; | |||
} catch (error) { | |||
Logger.error(error, 'getLocalWorkspaceData'); | |||
} | |||
} | |||
return { workspaces: {} }; | |||
} | |||
async writeCodeWorkspaceFile( | |||
uri: Uri, | |||
workspaceRepoFilePaths: string[], | |||
options?: { workspaceId?: string }, | |||
): Promise<boolean> { | |||
let codeWorkspaceFileContents: CodeWorkspaceFileContents; | |||
let data; | |||
try { | |||
data = await workspace.fs.readFile(uri); | |||
codeWorkspaceFileContents = JSON.parse(data.toString()) as CodeWorkspaceFileContents; | |||
} catch (error) { | |||
codeWorkspaceFileContents = { folders: [], settings: {} }; | |||
} | |||
codeWorkspaceFileContents.folders = workspaceRepoFilePaths.map(repoFilePath => ({ path: repoFilePath })); | |||
if (options?.workspaceId != null) { | |||
codeWorkspaceFileContents.settings['gitkraken.workspaceId'] = options.workspaceId; | |||
} | |||
const outputData = new Uint8Array(Buffer.from(JSON.stringify(codeWorkspaceFileContents))); | |||
try { | |||
await workspace.fs.writeFile(uri, outputData); | |||
} catch (error) { | |||
Logger.error(error, 'writeCodeWorkspaceFile'); | |||
return false; | |||
} | |||
return true; | |||
} | |||
} |
@ -0,0 +1,11 @@ | |||
export type LocalRepoDataMap = { | |||
[key: string /* key can be remote url, provider/owner/name, or first commit SHA*/]: RepoLocalData; | |||
}; | |||
export interface RepoLocalData { | |||
paths: string[]; | |||
name?: string; | |||
hostName?: string; | |||
owner?: string; | |||
hostingServiceType?: string; | |||
} |
@ -0,0 +1,13 @@ | |||
import type { Disposable } from 'vscode'; | |||
export interface RepositoryPathMappingProvider extends Disposable { | |||
getLocalRepoPaths(options: { | |||
remoteUrl?: string; | |||
repoInfo?: { provider: string; owner: string; repoName: string }; | |||
}): Promise<string[]>; | |||
writeLocalRepoPath( | |||
options: { remoteUrl?: string; repoInfo?: { provider: string; owner: string; repoName: string } }, | |||
localPath: string, | |||
): Promise<void>; | |||
} |
@ -0,0 +1,557 @@ | |||
import type { Repository } from '../../git/models/repository'; | |||
export enum WorkspaceType { | |||
Local = 'local', | |||
Cloud = 'cloud', | |||
} | |||
export type CodeWorkspaceFileContents = { | |||
folders: { path: string }[]; | |||
settings: { [key: string]: any }; | |||
}; | |||
export type WorkspaceRepositoriesByName = Map<string, Repository>; | |||
export interface GetWorkspacesResponse { | |||
cloudWorkspaces: GKCloudWorkspace[]; | |||
localWorkspaces: GKLocalWorkspace[]; | |||
cloudWorkspaceInfo: string | undefined; | |||
localWorkspaceInfo: string | undefined; | |||
} | |||
export interface LoadCloudWorkspacesResponse { | |||
cloudWorkspaces: GKCloudWorkspace[] | undefined; | |||
cloudWorkspaceInfo: string | undefined; | |||
} | |||
export interface LoadLocalWorkspacesResponse { | |||
localWorkspaces: GKLocalWorkspace[] | undefined; | |||
localWorkspaceInfo: string | undefined; | |||
} | |||
export interface GetCloudWorkspaceRepositoriesResponse { | |||
repositories: CloudWorkspaceRepositoryDescriptor[] | undefined; | |||
repositoriesInfo: string | undefined; | |||
} | |||
// Cloud Workspace types | |||
export class GKCloudWorkspace { | |||
private readonly _type: WorkspaceType = WorkspaceType.Cloud; | |||
private readonly _id: string; | |||
private readonly _organizationId: string | undefined; | |||
private readonly _name: string; | |||
private readonly _provider: CloudWorkspaceProviderType; | |||
private _repositories: CloudWorkspaceRepositoryDescriptor[] | undefined; | |||
constructor( | |||
id: string, | |||
name: string, | |||
organizationId: string | undefined, | |||
provider: CloudWorkspaceProviderType, | |||
private readonly getReposFn: (workspaceId: string) => Promise<GetCloudWorkspaceRepositoriesResponse>, | |||
repositories?: CloudWorkspaceRepositoryDescriptor[], | |||
) { | |||
this._id = id; | |||
this._name = name; | |||
this._organizationId = organizationId; | |||
this._provider = provider; | |||
this._repositories = repositories; | |||
} | |||
get type(): WorkspaceType { | |||
return this._type; | |||
} | |||
get id(): string { | |||
return this._id; | |||
} | |||
get name(): string { | |||
return this._name; | |||
} | |||
get organization_id(): string | undefined { | |||
return this._organizationId; | |||
} | |||
get provider(): CloudWorkspaceProviderType { | |||
return this._provider; | |||
} | |||
get repositories(): CloudWorkspaceRepositoryDescriptor[] | undefined { | |||
return this._repositories; | |||
} | |||
isShared(): boolean { | |||
return this._organizationId != null; | |||
} | |||
getRepository(name: string): CloudWorkspaceRepositoryDescriptor | undefined { | |||
return this._repositories?.find(r => r.name === name); | |||
} | |||
addRepositories(repositories: CloudWorkspaceRepositoryDescriptor[]): void { | |||
if (this._repositories == null) { | |||
this._repositories = repositories; | |||
} else { | |||
this._repositories = this._repositories.concat(repositories); | |||
} | |||
} | |||
removeRepositories(repoNames: string[]): void { | |||
if (this._repositories == null) return; | |||
this._repositories = this._repositories.filter(r => !repoNames.includes(r.name)); | |||
} | |||
async getOrLoadRepositories(): Promise<GetCloudWorkspaceRepositoriesResponse> { | |||
if (this._repositories != null) return { repositories: this._repositories, repositoriesInfo: undefined }; | |||
const getResponse = await this.getReposFn(this._id); | |||
if (getResponse.repositories != null) { | |||
this._repositories = getResponse.repositories; | |||
} | |||
return getResponse; | |||
} | |||
} | |||
export interface CloudWorkspaceRepositoryDescriptor { | |||
id: string; | |||
name: string; | |||
description: string; | |||
repository_id: string; | |||
provider: string; | |||
provider_organization_id: string; | |||
provider_organization_name: string; | |||
url: string; | |||
} | |||
export enum CloudWorkspaceProviderInputType { | |||
GitHub = 'GITHUB', | |||
GitHubEnterprise = 'GITHUB_ENTERPRISE', | |||
GitLab = 'GITLAB', | |||
GitLabSelfHosted = 'GITLAB_SELF_HOSTED', | |||
Bitbucket = 'BITBUCKET', | |||
Azure = 'AZURE', | |||
} | |||
export enum CloudWorkspaceProviderType { | |||
GitHub = 'github', | |||
GitHubEnterprise = 'github_enterprise', | |||
GitLab = 'gitlab', | |||
GitLabSelfHosted = 'gitlab_self_hosted', | |||
Bitbucket = 'bitbucket', | |||
Azure = 'azure', | |||
} | |||
export const cloudWorkspaceProviderTypeToRemoteProviderId = { | |||
[CloudWorkspaceProviderType.Azure]: 'azure-devops', | |||
[CloudWorkspaceProviderType.Bitbucket]: 'bitbucket', | |||
[CloudWorkspaceProviderType.GitHub]: 'github', | |||
[CloudWorkspaceProviderType.GitHubEnterprise]: 'github', | |||
[CloudWorkspaceProviderType.GitLab]: 'gitlab', | |||
[CloudWorkspaceProviderType.GitLabSelfHosted]: 'gitlab', | |||
}; | |||
export const cloudWorkspaceProviderInputTypeToRemoteProviderId = { | |||
[CloudWorkspaceProviderInputType.Azure]: 'azure-devops', | |||
[CloudWorkspaceProviderInputType.Bitbucket]: 'bitbucket', | |||
[CloudWorkspaceProviderInputType.GitHub]: 'github', | |||
[CloudWorkspaceProviderInputType.GitHubEnterprise]: 'github', | |||
[CloudWorkspaceProviderInputType.GitLab]: 'gitlab', | |||
[CloudWorkspaceProviderInputType.GitLabSelfHosted]: 'gitlab', | |||
}; | |||
export const defaultWorkspaceCount = 100; | |||
export const defaultWorkspaceRepoCount = 100; | |||
export interface CloudWorkspaceData { | |||
id: string; | |||
name: string; | |||
description: string; | |||
type: CloudWorkspaceType; | |||
icon_url: string; | |||
host_url: string; | |||
status: string; | |||
provider: string; | |||
azure_organization_id: string; | |||
azure_project: string; | |||
created_date: Date; | |||
updated_date: Date; | |||
created_by: string; | |||
updated_by: string; | |||
members: CloudWorkspaceMember[]; | |||
organization: CloudWorkspaceOrganization; | |||
issue_tracker: CloudWorkspaceIssueTracker; | |||
settings: CloudWorkspaceSettings; | |||
current_user: UserCloudWorkspaceSettings; | |||
errors: string[]; | |||
provider_data: ProviderCloudWorkspaceData; | |||
} | |||
export type CloudWorkspaceType = 'GK_PROJECT' | 'GK_ORG_VELOCITY' | 'GK_CLI'; | |||
export interface CloudWorkspaceMember { | |||
id: string; | |||
role: string; | |||
name: string; | |||
username: string; | |||
avatar_url: string; | |||
} | |||
interface CloudWorkspaceOrganization { | |||
id: string; | |||
team_ids: string[]; | |||
} | |||
interface CloudWorkspaceIssueTracker { | |||
provider: string; | |||
settings: CloudWorkspaceIssueTrackerSettings; | |||
} | |||
interface CloudWorkspaceIssueTrackerSettings { | |||
resource_id: string; | |||
} | |||
interface CloudWorkspaceSettings { | |||
gkOrgVelocity: GKOrgVelocitySettings; | |||
goals: ProjectGoalsSettings; | |||
} | |||
type GKOrgVelocitySettings = Record<string, unknown>; | |||
type ProjectGoalsSettings = Record<string, unknown>; | |||
interface UserCloudWorkspaceSettings { | |||
project_id: string; | |||
user_id: string; | |||
tab_settings: UserCloudWorkspaceTabSettings; | |||
} | |||
interface UserCloudWorkspaceTabSettings { | |||
issue_tracker: CloudWorkspaceIssueTracker; | |||
} | |||
export interface ProviderCloudWorkspaceData { | |||
id: string; | |||
provider_organization_id: string; | |||
repository: CloudWorkspaceRepositoryData; | |||
repositories: CloudWorkspaceConnection<CloudWorkspaceRepositoryData>; | |||
pull_requests: CloudWorkspacePullRequestData[]; | |||
issues: CloudWorkspaceIssue[]; | |||
repository_members: CloudWorkspaceRepositoryMemberData[]; | |||
milestones: CloudWorkspaceMilestone[]; | |||
labels: CloudWorkspaceLabel[]; | |||
issue_types: CloudWorkspaceIssueType[]; | |||
provider_identity: ProviderCloudWorkspaceIdentity; | |||
metrics: ProviderCloudWorkspaceMetrics; | |||
} | |||
type ProviderCloudWorkspaceMetrics = Record<string, unknown>; | |||
interface ProviderCloudWorkspaceIdentity { | |||
avatar_url: string; | |||
id: string; | |||
name: string; | |||
username: string; | |||
pat_organization: string; | |||
is_using_pat: boolean; | |||
scopes: string; | |||
} | |||
export interface Branch { | |||
id: string; | |||
node_id: string; | |||
name: string; | |||
commit: BranchCommit; | |||
} | |||
interface BranchCommit { | |||
id: string; | |||
url: string; | |||
build_status: { | |||
context: string; | |||
state: string; | |||
description: string; | |||
}; | |||
} | |||
export interface CloudWorkspaceRepositoryData { | |||
id: string; | |||
name: string; | |||
description: string; | |||
repository_id: string; | |||
provider: string; | |||
provider_organization_id: string; | |||
provider_organization_name: string; | |||
url: string; | |||
default_branch: string; | |||
branches: Branch[]; | |||
pull_requests: CloudWorkspacePullRequestData[]; | |||
issues: CloudWorkspaceIssue[]; | |||
members: CloudWorkspaceRepositoryMemberData[]; | |||
milestones: CloudWorkspaceMilestone[]; | |||
labels: CloudWorkspaceLabel[]; | |||
issue_types: CloudWorkspaceIssueType[]; | |||
possibly_deleted: boolean; | |||
has_webhook: boolean; | |||
} | |||
interface CloudWorkspaceRepositoryMemberData { | |||
avatar_url: string; | |||
name: string; | |||
node_id: string; | |||
username: string; | |||
} | |||
type CloudWorkspaceMilestone = Record<string, unknown>; | |||
type CloudWorkspaceLabel = Record<string, unknown>; | |||
type CloudWorkspaceIssueType = Record<string, unknown>; | |||
export interface CloudWorkspacePullRequestData { | |||
id: string; | |||
node_id: string; | |||
number: string; | |||
title: string; | |||
description: string; | |||
url: string; | |||
milestone_id: string; | |||
labels: CloudWorkspaceLabel[]; | |||
author_id: string; | |||
author_username: string; | |||
created_date: Date; | |||
updated_date: Date; | |||
closed_date: Date; | |||
merged_date: Date; | |||
first_commit_date: Date; | |||
first_response_date: Date; | |||
comment_count: number; | |||
repository: CloudWorkspaceRepositoryData; | |||
head_commit: { | |||
id: string; | |||
url: string; | |||
build_status: { | |||
context: string; | |||
state: string; | |||
description: string; | |||
}; | |||
}; | |||
lifecycle_stages: { | |||
stage: string; | |||
start_date: Date; | |||
end_date: Date; | |||
}[]; | |||
reviews: CloudWorkspacePullRequestReviews[]; | |||
head: { | |||
name: string; | |||
}; | |||
} | |||
interface CloudWorkspacePullRequestReviews { | |||
user_id: string; | |||
avatar_url: string; | |||
state: string; | |||
} | |||
export interface CloudWorkspaceIssue { | |||
id: string; | |||
node_id: string; | |||
title: string; | |||
author_id: string; | |||
assignee_ids: string[]; | |||
milestone_id: string; | |||
label_ids: string[]; | |||
issue_type: string; | |||
url: string; | |||
created_date: Date; | |||
updated_date: Date; | |||
comment_count: number; | |||
repository: CloudWorkspaceRepositoryData; | |||
} | |||
interface CloudWorkspaceConnection<i> { | |||
total_count: number; | |||
page_info: { | |||
start_cursor: string; | |||
end_cursor: string; | |||
has_next_page: boolean; | |||
}; | |||
nodes: i[]; | |||
} | |||
interface CloudWorkspaceFetchedConnection<i> extends CloudWorkspaceConnection<i> { | |||
is_fetching: boolean; | |||
} | |||
export interface WorkspacesResponse { | |||
data: { | |||
projects: CloudWorkspaceConnection<CloudWorkspaceData>; | |||
}; | |||
} | |||
export interface WorkspaceRepositoriesResponse { | |||
data: { | |||
project: { | |||
provider_data: { | |||
repositories: CloudWorkspaceConnection<CloudWorkspaceRepositoryData>; | |||
}; | |||
}; | |||
}; | |||
} | |||
export interface WorkspacePullRequestsResponse { | |||
data: { | |||
project: { | |||
provider_data: { | |||
pull_requests: CloudWorkspaceFetchedConnection<CloudWorkspacePullRequestData>; | |||
}; | |||
}; | |||
}; | |||
} | |||
export interface WorkspacesWithPullRequestsResponse { | |||
data: { | |||
projects: { | |||
nodes: { | |||
provider_data: { | |||
pull_requests: CloudWorkspaceFetchedConnection<CloudWorkspacePullRequestData>; | |||
}; | |||
}[]; | |||
}; | |||
}; | |||
errors?: { | |||
message: string; | |||
path: unknown[]; | |||
statusCode: number; | |||
}[]; | |||
} | |||
export interface WorkspaceIssuesResponse { | |||
data: { | |||
project: { | |||
provider_data: { | |||
issues: CloudWorkspaceFetchedConnection<CloudWorkspaceIssue>; | |||
}; | |||
}; | |||
}; | |||
} | |||
export interface CreateWorkspaceResponse { | |||
data: { | |||
create_project: CloudWorkspaceData | null; | |||
}; | |||
} | |||
export interface DeleteWorkspaceResponse { | |||
data: { | |||
delete_project: CloudWorkspaceData | null; | |||
}; | |||
} | |||
export type AddRepositoriesToWorkspaceResponse = { | |||
data: { | |||
add_repositories_to_project: { | |||
id: string; | |||
provider_data: { | |||
[repoKey: string]: CloudWorkspaceRepositoryData; | |||
}; | |||
} | null; | |||
}; | |||
}; | |||
export interface RemoveRepositoriesFromWorkspaceResponse { | |||
data: { | |||
remove_repositories_from_project: { | |||
id: string; | |||
} | null; | |||
}; | |||
} | |||
export interface AddWorkspaceRepoDescriptor { | |||
owner: string; | |||
repoName: string; | |||
} | |||
// TODO@ramint Switch to using repo id once that is no longer bugged | |||
export interface RemoveWorkspaceRepoDescriptor { | |||
owner: string; | |||
repoName: string; | |||
} | |||
// Local Workspace Types | |||
export class GKLocalWorkspace { | |||
private readonly _type: WorkspaceType = WorkspaceType.Local; | |||
private readonly _id: string; | |||
private readonly _name: string; | |||
private readonly _repositories: LocalWorkspaceRepositoryDescriptor[] | undefined; | |||
constructor(id: string, name: string, repositories?: LocalWorkspaceRepositoryDescriptor[]) { | |||
this._id = id; | |||
this._name = name; | |||
this._repositories = repositories; | |||
} | |||
get type(): WorkspaceType { | |||
return this._type; | |||
} | |||
get id(): string { | |||
return this._id; | |||
} | |||
get name(): string { | |||
return this._name; | |||
} | |||
get repositories(): LocalWorkspaceRepositoryDescriptor[] | undefined { | |||
return this._repositories; | |||
} | |||
isShared(): boolean { | |||
return false; | |||
} | |||
getRepository(name: string): LocalWorkspaceRepositoryDescriptor | undefined { | |||
return this._repositories?.find(r => r.name === name); | |||
} | |||
} | |||
export interface LocalWorkspaceFileData { | |||
workspaces: LocalWorkspaceData; | |||
} | |||
export type LocalWorkspaceData = { | |||
[localWorkspaceId: string]: LocalWorkspaceDescriptor; | |||
}; | |||
export interface LocalWorkspaceDescriptor { | |||
localId: string; | |||
profileId: string; | |||
name: string; | |||
description: string; | |||
repositories: LocalWorkspaceRepositoryPath[]; | |||
version: number; | |||
} | |||
export interface LocalWorkspaceRepositoryPath { | |||
localPath: string; | |||
} | |||
export interface LocalWorkspaceRepositoryDescriptor extends LocalWorkspaceRepositoryPath { | |||
id?: undefined; | |||
name: string; | |||
} | |||
export interface CloudWorkspaceFileData { | |||
workspaces: CloudWorkspacesPathMap; | |||
} | |||
export type CloudWorkspacesPathMap = { | |||
[cloudWorkspaceId: string]: CloudWorkspaceRepoPaths; | |||
}; | |||
export interface CloudWorkspaceRepoPaths { | |||
repoPaths: CloudWorkspaceRepoPathMap; | |||
} | |||
export type CloudWorkspaceRepoPathMap = { | |||
[repoId: string]: string; | |||
}; |
@ -0,0 +1,443 @@ | |||
import type { Container } from '../../container'; | |||
import { Logger } from '../../system/logger'; | |||
import type { ServerConnection } from '../subscription/serverConnection'; | |||
import type { | |||
AddRepositoriesToWorkspaceResponse, | |||
AddWorkspaceRepoDescriptor, | |||
CreateWorkspaceResponse, | |||
DeleteWorkspaceResponse, | |||
RemoveRepositoriesFromWorkspaceResponse, | |||
RemoveWorkspaceRepoDescriptor, | |||
WorkspaceRepositoriesResponse, | |||
WorkspacesResponse, | |||
} from './models'; | |||
import { CloudWorkspaceProviderInputType, defaultWorkspaceCount, defaultWorkspaceRepoCount } from './models'; | |||
export class WorkspacesApi { | |||
constructor(private readonly container: Container, private readonly server: ServerConnection) {} | |||
private async getAccessToken() { | |||
// TODO: should probably get scopes from somewhere | |||
const sessions = await this.container.subscriptionAuthentication.getSessions(['gitlens']); | |||
if (!sessions.length) { | |||
return; | |||
} | |||
const session = sessions[0]; | |||
return session.accessToken; | |||
} | |||
// TODO@ramint: We have a pagedresponse model available in case it helps here. Takes care of cursor internally | |||
// Make the data return a promise for the repos. Should be async so we're set up for dynamic processing. | |||
async getWorkspacesWithRepos(options?: { | |||
count?: number; | |||
cursor?: string; | |||
page?: number; | |||
repoCount?: number; | |||
repoPage?: number; | |||
}): Promise<WorkspacesResponse | undefined> { | |||
const accessToken = await this.getAccessToken(); | |||
if (accessToken == null) { | |||
return; | |||
} | |||
let queryParams = `(first: ${options?.count ?? defaultWorkspaceCount}`; | |||
if (options?.cursor) { | |||
queryParams += `, after: "${options.cursor}"`; | |||
} else if (options?.page) { | |||
queryParams += `, page: ${options.page}`; | |||
} | |||
queryParams += ')'; | |||
let repoQueryParams = `(first: ${options?.repoCount ?? defaultWorkspaceRepoCount}`; | |||
if (options?.repoPage) { | |||
repoQueryParams += `, page: ${options.repoPage}`; | |||
} | |||
repoQueryParams += ')'; | |||
const rsp = await this.server.fetchGraphql( | |||
{ | |||
query: ` | |||
query getWorkspacesWithRepos { | |||
projects ${queryParams} { | |||
total_count | |||
page_info { | |||
end_cursor | |||
has_next_page | |||
} | |||
nodes { | |||
id | |||
description | |||
name | |||
organization { | |||
id | |||
} | |||
provider | |||
provider_data { | |||
repositories ${repoQueryParams} { | |||
total_count | |||
page_info { | |||
end_cursor | |||
has_next_page | |||
} | |||
nodes { | |||
id | |||
name | |||
repository_id | |||
provider | |||
provider_organization_id | |||
provider_organization_name | |||
url | |||
} | |||
} | |||
} | |||
} | |||
} | |||
} | |||
`, | |||
}, | |||
accessToken, | |||
); | |||
if (!rsp.ok) { | |||
Logger.error(undefined, `Getting workspaces with repos failed: (${rsp.status}) ${rsp.statusText}`); | |||
throw new Error(rsp.statusText); | |||
} | |||
const json: WorkspacesResponse | undefined = (await rsp.json()) as WorkspacesResponse | undefined; | |||
return json; | |||
} | |||
async getWorkspaces(options?: { | |||
count?: number; | |||
cursor?: string; | |||
page?: number; | |||
}): Promise<WorkspacesResponse | undefined> { | |||
const accessToken = await this.getAccessToken(); | |||
if (accessToken == null) { | |||
return; | |||
} | |||
let queryparams = `(first: ${options?.count ?? defaultWorkspaceCount}`; | |||
if (options?.cursor) { | |||
queryparams += `, after: "${options.cursor}"`; | |||
} else if (options?.page) { | |||
queryparams += `, page: ${options.page}`; | |||
} | |||
queryparams += ')'; | |||
const rsp = await this.server.fetchGraphql( | |||
{ | |||
query: ` | |||
query getWorkspaces { | |||
projects ${queryparams} { | |||
total_count | |||
page_info { | |||
end_cursor | |||
has_next_page | |||
} | |||
nodes { | |||
id | |||
description | |||
name | |||
organization { | |||
id | |||
} | |||
provider | |||
} | |||
} | |||
} | |||
`, | |||
}, | |||
accessToken, | |||
); | |||
if (!rsp.ok) { | |||
Logger.error(undefined, `Getting workspaces failed: (${rsp.status}) ${rsp.statusText}`); | |||
throw new Error(rsp.statusText); | |||
} | |||
const json: WorkspacesResponse | undefined = (await rsp.json()) as WorkspacesResponse | undefined; | |||
return json; | |||
} | |||
async getWorkspaceRepositories( | |||
workspaceId: string, | |||
options?: { | |||
count?: number; | |||
cursor?: string; | |||
page?: number; | |||
}, | |||
): Promise<WorkspaceRepositoriesResponse | undefined> { | |||
const accessToken = await this.getAccessToken(); | |||
if (accessToken == null) { | |||
return; | |||
} | |||
let queryparams = `(first: ${options?.count ?? defaultWorkspaceRepoCount}`; | |||
if (options?.cursor) { | |||
queryparams += `, after: "${options.cursor}"`; | |||
} else if (options?.page) { | |||
queryparams += `, page: ${options.page}`; | |||
} | |||
queryparams += ')'; | |||
const rsp = await this.server.fetchGraphql( | |||
{ | |||
query: ` | |||
query getWorkspaceRepos { | |||
project (id: "${workspaceId}") { | |||
provider_data { | |||
repositories ${queryparams} { | |||
total_count | |||
page_info { | |||
end_cursor | |||
has_next_page | |||
} | |||
nodes { | |||
id | |||
name | |||
repository_id | |||
provider | |||
provider_organization_id | |||
provider_organization_name | |||
url | |||
} | |||
} | |||
} | |||
} | |||
} | |||
`, | |||
}, | |||
accessToken, | |||
); | |||
if (!rsp.ok) { | |||
Logger.error(undefined, `Getting workspace repos failed: (${rsp.status}) ${rsp.statusText}`); | |||
throw new Error(rsp.statusText); | |||
} | |||
const json: WorkspaceRepositoriesResponse | undefined = (await rsp.json()) as | |||
| WorkspaceRepositoriesResponse | |||
| undefined; | |||
return json; | |||
} | |||
async createWorkspace(options: { | |||
name: string; | |||
description: string; | |||
provider: CloudWorkspaceProviderInputType; | |||
hostUrl?: string; | |||
azureOrganizationName?: string; | |||
azureProjectName?: string; | |||
}): Promise<CreateWorkspaceResponse | undefined> { | |||
if (!options.name || !options.description || !options.provider) { | |||
return; | |||
} | |||
if ( | |||
options.provider === CloudWorkspaceProviderInputType.Azure && | |||
(!options.azureOrganizationName || !options.azureProjectName) | |||
) { | |||
return; | |||
} | |||
if ( | |||
(options.provider === CloudWorkspaceProviderInputType.GitHubEnterprise || | |||
options.provider === CloudWorkspaceProviderInputType.GitLabSelfHosted) && | |||
!options.hostUrl | |||
) { | |||
return; | |||
} | |||
const accessToken = await this.getAccessToken(); | |||
if (accessToken == null) { | |||
return; | |||
} | |||
const rsp = await this.server.fetchGraphql( | |||
{ | |||
query: ` | |||
mutation createWorkspace { | |||
create_project( | |||
input: { | |||
type: GK_PROJECT | |||
name: "${options.name}" | |||
description: "${options.description}" | |||
provider: ${options.provider} | |||
${options.hostUrl ? `host_url: "${options.hostUrl}"` : ''} | |||
${options.azureOrganizationName ? `azure_organization_id: "${options.azureOrganizationName}"` : ''} | |||
${options.azureProjectName ? `azure_project: "${options.azureProjectName}"` : ''} | |||
profile_id: "shared-services" | |||
} | |||
) { | |||
id, | |||
name, | |||
description, | |||
organization { | |||
id | |||
} | |||
provider | |||
} | |||
} | |||
`, | |||
}, | |||
accessToken, | |||
); | |||
if (!rsp.ok) { | |||
Logger.error(undefined, `Creating workspace failed: (${rsp.status}) ${rsp.statusText}`); | |||
throw new Error(rsp.statusText); | |||
} | |||
const json: CreateWorkspaceResponse | undefined = (await rsp.json()) as CreateWorkspaceResponse | undefined; | |||
return json; | |||
} | |||
async deleteWorkspace(workspaceId: string): Promise<DeleteWorkspaceResponse | undefined> { | |||
const accessToken = await this.getAccessToken(); | |||
if (accessToken == null) { | |||
return; | |||
} | |||
const rsp = await this.server.fetchGraphql( | |||
{ | |||
query: ` | |||
mutation deleteWorkspace { | |||
delete_project( | |||
id: "${workspaceId}" | |||
) { | |||
id | |||
} | |||
} | |||
`, | |||
}, | |||
accessToken, | |||
); | |||
if (!rsp.ok) { | |||
Logger.error(undefined, `Deleting workspace failed: (${rsp.status}) ${rsp.statusText}`); | |||
throw new Error(rsp.statusText); | |||
} | |||
const json: DeleteWorkspaceResponse | undefined = (await rsp.json()) as DeleteWorkspaceResponse | undefined; | |||
return json; | |||
} | |||
async addReposToWorkspace( | |||
workspaceId: string, | |||
repos: AddWorkspaceRepoDescriptor[], | |||
): Promise<AddRepositoriesToWorkspaceResponse | undefined> { | |||
if (repos.length === 0) { | |||
return; | |||
} | |||
const accessToken = await this.getAccessToken(); | |||
if (accessToken == null) { | |||
return; | |||
} | |||
let reposQuery = '['; | |||
reposQuery += repos.map(r => `{ provider_organization_id: "${r.owner}", name: "${r.repoName}" }`).join(','); | |||
reposQuery += ']'; | |||
let count = 1; | |||
const reposReturnQuery = repos | |||
.map( | |||
r => `Repository${count++}: repository(provider_organization_id: "${r.owner}", name: "${r.repoName}") { | |||
id | |||
name | |||
repository_id | |||
provider | |||
provider_organization_id | |||
provider_organization_name | |||
url | |||
}`, | |||
) | |||
.join(','); | |||
const rsp = await this.server.fetchGraphql( | |||
{ | |||
query: ` | |||
mutation addReposToWorkspace { | |||
add_repositories_to_project( | |||
input: { | |||
project_id: "${workspaceId}", | |||
repositories: ${reposQuery} | |||
} | |||
) { | |||
id | |||
provider_data { | |||
${reposReturnQuery} | |||
} | |||
} | |||
} | |||
`, | |||
}, | |||
accessToken, | |||
); | |||
if (!rsp.ok) { | |||
Logger.error(undefined, `Adding repositories to workspace failed: (${rsp.status}) ${rsp.statusText}`); | |||
throw new Error(rsp.statusText); | |||
} | |||
const json: AddRepositoriesToWorkspaceResponse | undefined = (await rsp.json()) as | |||
| AddRepositoriesToWorkspaceResponse | |||
| undefined; | |||
return json; | |||
} | |||
async removeReposFromWorkspace( | |||
workspaceId: string, | |||
repos: RemoveWorkspaceRepoDescriptor[], | |||
): Promise<RemoveRepositoriesFromWorkspaceResponse | undefined> { | |||
if (repos.length === 0) { | |||
return; | |||
} | |||
const accessToken = await this.getAccessToken(); | |||
if (accessToken == null) { | |||
return; | |||
} | |||
let reposQuery = '['; | |||
reposQuery += repos.map(r => `{ provider_organization_id: "${r.owner}", name: "${r.repoName}" }`).join(','); | |||
reposQuery += ']'; | |||
const rsp = await this.server.fetchGraphql( | |||
{ | |||
query: ` | |||
mutation removeReposFromWorkspace { | |||
remove_repositories_from_project( | |||
input: { | |||
project_id: "${workspaceId}", | |||
repositories: ${reposQuery} | |||
} | |||
) { | |||
id | |||
} | |||
} | |||
`, | |||
}, | |||
accessToken, | |||
); | |||
if (!rsp.ok) { | |||
Logger.error(undefined, `Removing repositories from workspace failed: (${rsp.status}) ${rsp.statusText}`); | |||
throw new Error(rsp.statusText); | |||
} | |||
const json: RemoveRepositoriesFromWorkspaceResponse | undefined = (await rsp.json()) as | |||
| RemoveRepositoriesFromWorkspaceResponse | |||
| undefined; | |||
return json; | |||
} | |||
} |
@ -0,0 +1,16 @@ | |||
import type { Uri } from 'vscode'; | |||
import type { LocalWorkspaceFileData } from './models'; | |||
export interface WorkspacesPathMappingProvider { | |||
getCloudWorkspaceRepoPath(cloudWorkspaceId: string, repoId: string): Promise<string | undefined>; | |||
writeCloudWorkspaceDiskPathToMap(cloudWorkspaceId: string, repoId: string, repoLocalPath: string): Promise<void>; | |||
getLocalWorkspaceData(): Promise<LocalWorkspaceFileData>; | |||
writeCodeWorkspaceFile( | |||
uri: Uri, | |||
workspaceRepoFilePaths: string[], | |||
options?: { workspaceId?: string }, | |||
): Promise<boolean>; | |||
} |
@ -0,0 +1,902 @@ | |||
import type { CancellationToken, Event } from 'vscode'; | |||
import { Disposable, EventEmitter, Uri, window } from 'vscode'; | |||
import { getSupportedWorkspacesPathMappingProvider } from '@env/providers'; | |||
import type { Container } from '../../container'; | |||
import { RemoteResourceType } from '../../git/models/remoteResource'; | |||
import type { Repository } from '../../git/models/repository'; | |||
import { showRepositoryPicker } from '../../quickpicks/repositoryPicker'; | |||
import { SubscriptionState } from '../../subscription'; | |||
import { openWorkspace, OpenWorkspaceLocation } from '../../system/utils'; | |||
import type { ServerConnection } from '../subscription/serverConnection'; | |||
import type { SubscriptionChangeEvent } from '../subscription/subscriptionService'; | |||
import type { | |||
AddWorkspaceRepoDescriptor, | |||
CloudWorkspaceData, | |||
CloudWorkspaceProviderType, | |||
CloudWorkspaceRepositoryDescriptor, | |||
GetCloudWorkspaceRepositoriesResponse, | |||
GetWorkspacesResponse, | |||
LoadCloudWorkspacesResponse, | |||
LoadLocalWorkspacesResponse, | |||
LocalWorkspaceData, | |||
LocalWorkspaceRepositoryDescriptor, | |||
WorkspaceRepositoriesByName, | |||
WorkspacesResponse, | |||
} from './models'; | |||
import { | |||
CloudWorkspaceProviderInputType, | |||
cloudWorkspaceProviderInputTypeToRemoteProviderId, | |||
cloudWorkspaceProviderTypeToRemoteProviderId, | |||
GKCloudWorkspace, | |||
GKLocalWorkspace, | |||
WorkspaceType, | |||
} from './models'; | |||
import { WorkspacesApi } from './workspacesApi'; | |||
import type { WorkspacesPathMappingProvider } from './workspacesPathMappingProvider'; | |||
export class WorkspacesService implements Disposable { | |||
private _cloudWorkspaces: GKCloudWorkspace[] | undefined = undefined; | |||
private _localWorkspaces: GKLocalWorkspace[] | undefined = undefined; | |||
private _workspacesApi: WorkspacesApi; | |||
private _workspacesPathProvider: WorkspacesPathMappingProvider; | |||
private _onDidChangeWorkspaces: EventEmitter<void> = new EventEmitter<void>(); | |||
get onDidChangeWorkspaces(): Event<void> { | |||
return this._onDidChangeWorkspaces.event; | |||
} | |||
private _disposable: Disposable; | |||
// TODO@ramint Add error handling/logging when this is used. | |||
private readonly _getCloudWorkspaceRepos: (workspaceId: string) => Promise<GetCloudWorkspaceRepositoriesResponse> = | |||
async (workspaceId: string) => { | |||
try { | |||
const workspaceRepos = await this._workspacesApi.getWorkspaceRepositories(workspaceId); | |||
return { | |||
repositories: workspaceRepos?.data?.project?.provider_data?.repositories?.nodes ?? [], | |||
repositoriesInfo: undefined, | |||
}; | |||
} catch { | |||
return { | |||
repositories: undefined, | |||
repositoriesInfo: 'Failed to load repositories for this workspace.', | |||
}; | |||
} | |||
}; | |||
constructor(private readonly container: Container, private readonly server: ServerConnection) { | |||
this._workspacesApi = new WorkspacesApi(this.container, this.server); | |||
this._workspacesPathProvider = getSupportedWorkspacesPathMappingProvider(); | |||
this._disposable = Disposable.from(container.subscription.onDidChange(this.onSubscriptionChanged, this)); | |||
} | |||
dispose(): void { | |||
this._disposable.dispose(); | |||
} | |||
private onSubscriptionChanged(event: SubscriptionChangeEvent): void { | |||
if ( | |||
event.current.account == null || | |||
event.current.account.id !== event.previous?.account?.id || | |||
event.current.state !== event.previous?.state | |||
) { | |||
this.resetWorkspaces({ cloud: true }); | |||
this._onDidChangeWorkspaces.fire(); | |||
} | |||
} | |||
private async loadCloudWorkspaces(excludeRepositories: boolean = false): Promise<LoadCloudWorkspacesResponse> { | |||
const subscription = await this.container.subscription.getSubscription(); | |||
if (subscription?.account == null) { | |||
return { | |||
cloudWorkspaces: undefined, | |||
cloudWorkspaceInfo: 'Please sign in to use cloud workspaces.', | |||
}; | |||
} | |||
const cloudWorkspaces: GKCloudWorkspace[] = []; | |||
let workspaces: CloudWorkspaceData[] | undefined; | |||
try { | |||
const workspaceResponse: WorkspacesResponse | undefined = excludeRepositories | |||
? await this._workspacesApi.getWorkspaces() | |||
: await this._workspacesApi.getWorkspacesWithRepos(); | |||
workspaces = workspaceResponse?.data?.projects?.nodes; | |||
} catch { | |||
return { | |||
cloudWorkspaces: undefined, | |||
cloudWorkspaceInfo: 'Failed to load cloud workspaces.', | |||
}; | |||
} | |||
let filteredSharedWorkspaceCount = 0; | |||
const isPlusEnabled = | |||
subscription.state === SubscriptionState.FreeInPreviewTrial || | |||
subscription.state === SubscriptionState.FreePlusInTrial || | |||
subscription.state === SubscriptionState.Paid; | |||
if (workspaces?.length) { | |||
for (const workspace of workspaces) { | |||
if (!isPlusEnabled && workspace.organization?.id) { | |||
filteredSharedWorkspaceCount += 1; | |||
continue; | |||
} | |||
let repositories: CloudWorkspaceRepositoryDescriptor[] | undefined = | |||
workspace.provider_data?.repositories?.nodes; | |||
if (repositories == null && !excludeRepositories) { | |||
repositories = []; | |||
} | |||
cloudWorkspaces.push( | |||
new GKCloudWorkspace( | |||
workspace.id, | |||
workspace.name, | |||
workspace.organization?.id, | |||
workspace.provider as CloudWorkspaceProviderType, | |||
this._getCloudWorkspaceRepos, | |||
repositories, | |||
), | |||
); | |||
} | |||
} | |||
return { | |||
cloudWorkspaces: cloudWorkspaces, | |||
cloudWorkspaceInfo: | |||
filteredSharedWorkspaceCount > 0 | |||
? `${filteredSharedWorkspaceCount} shared workspaces hidden - upgrade to GitLens Pro to access.` | |||
: undefined, | |||
}; | |||
} | |||
// TODO@ramint: When we interact more with local workspaces, this should return more info about failures. | |||
private async loadLocalWorkspaces(): Promise<LoadLocalWorkspacesResponse> { | |||
const localWorkspaces: GKLocalWorkspace[] = []; | |||
const workspaceFileData: LocalWorkspaceData = | |||
(await this._workspacesPathProvider.getLocalWorkspaceData())?.workspaces || {}; | |||
for (const workspace of Object.values(workspaceFileData)) { | |||
localWorkspaces.push( | |||
new GKLocalWorkspace( | |||
workspace.localId, | |||
workspace.name, | |||
workspace.repositories.map(repositoryPath => ({ | |||
localPath: repositoryPath.localPath, | |||
name: repositoryPath.localPath.split(/[\\/]/).pop() ?? 'unknown', | |||
})), | |||
), | |||
); | |||
} | |||
return { | |||
localWorkspaces: localWorkspaces, | |||
localWorkspaceInfo: undefined, | |||
}; | |||
} | |||
private getCloudWorkspace(workspaceId: string): GKCloudWorkspace | undefined { | |||
return this._cloudWorkspaces?.find(workspace => workspace.id === workspaceId); | |||
} | |||
private getLocalWorkspace(workspaceId: string): GKLocalWorkspace | undefined { | |||
return this._localWorkspaces?.find(workspace => workspace.id === workspaceId); | |||
} | |||
async getWorkspaces(options?: { excludeRepositories?: boolean; force?: boolean }): Promise<GetWorkspacesResponse> { | |||
const getWorkspacesResponse: GetWorkspacesResponse = { | |||
cloudWorkspaces: [], | |||
localWorkspaces: [], | |||
cloudWorkspaceInfo: undefined, | |||
localWorkspaceInfo: undefined, | |||
}; | |||
if (this._cloudWorkspaces == null || options?.force) { | |||
const loadCloudWorkspacesResponse = await this.loadCloudWorkspaces(options?.excludeRepositories); | |||
this._cloudWorkspaces = loadCloudWorkspacesResponse.cloudWorkspaces; | |||
getWorkspacesResponse.cloudWorkspaceInfo = loadCloudWorkspacesResponse.cloudWorkspaceInfo; | |||
} | |||
if (this._localWorkspaces == null || options?.force) { | |||
const loadLocalWorkspacesResponse = await this.loadLocalWorkspaces(); | |||
this._localWorkspaces = loadLocalWorkspacesResponse.localWorkspaces; | |||
getWorkspacesResponse.localWorkspaceInfo = loadLocalWorkspacesResponse.localWorkspaceInfo; | |||
} | |||
getWorkspacesResponse.cloudWorkspaces = this._cloudWorkspaces ?? []; | |||
getWorkspacesResponse.localWorkspaces = this._localWorkspaces ?? []; | |||
return getWorkspacesResponse; | |||
} | |||
resetWorkspaces(options?: { cloud?: boolean; local?: boolean }) { | |||
if (options?.cloud ?? true) { | |||
this._cloudWorkspaces = undefined; | |||
} | |||
if (options?.local ?? true) { | |||
this._localWorkspaces = undefined; | |||
} | |||
} | |||
async getCloudWorkspaceRepoPath(cloudWorkspaceId: string, repoId: string): Promise<string | undefined> { | |||
return this._workspacesPathProvider.getCloudWorkspaceRepoPath(cloudWorkspaceId, repoId); | |||
} | |||
async updateCloudWorkspaceRepoLocalPath(workspaceId: string, repoId: string, localPath: string): Promise<void> { | |||
await this._workspacesPathProvider.writeCloudWorkspaceDiskPathToMap(workspaceId, repoId, localPath); | |||
} | |||
async locateAllCloudWorkspaceRepos(workspaceId: string, cancellation?: CancellationToken): Promise<void> { | |||
const workspace = this.getCloudWorkspace(workspaceId); | |||
if (workspace == null) return; | |||
const repoDescriptors = workspace.repositories; | |||
if (repoDescriptors == null || repoDescriptors.length === 0) return; | |||
const parentUri = ( | |||
await window.showOpenDialog({ | |||
title: `Choose a folder containing the repositories in this workspace`, | |||
canSelectFiles: false, | |||
canSelectFolders: true, | |||
canSelectMany: false, | |||
}) | |||
)?.[0]; | |||
if (parentUri == null || cancellation?.isCancellationRequested) return; | |||
let foundRepos; | |||
try { | |||
foundRepos = await this.container.git.findRepositories(parentUri, { | |||
cancellation: cancellation, | |||
depth: 1, | |||
silent: true, | |||
}); | |||
} catch (ex) { | |||
foundRepos = []; | |||
return; | |||
} | |||
if (foundRepos.length === 0 || cancellation?.isCancellationRequested) return; | |||
// Map repos by provider/owner/name | |||
const foundReposMap = new Map<string, Repository>(); | |||
const foundReposNameMap = new Map<string, Repository>(); | |||
for (const repo of foundRepos) { | |||
foundReposNameMap.set(repo.name.toLowerCase(), repo); | |||
if (cancellation?.isCancellationRequested) break; | |||
const remotes = await repo.getRemotes(); | |||
for (const remote of remotes) { | |||
if (remote.provider?.owner == null) continue; | |||
foundReposMap.set( | |||
`${remote.provider.id.toLowerCase()}/${remote.provider.owner.toLowerCase()}/${remote.provider.path | |||
.split('/') | |||
.pop() | |||
?.toLowerCase()}`, | |||
repo, | |||
); | |||
} | |||
} | |||
for (const repoDescriptor of repoDescriptors) { | |||
const foundRepo = | |||
foundReposMap.get( | |||
`${repoDescriptor.provider.toLowerCase()}/${repoDescriptor.provider_organization_id.toLowerCase()}/${repoDescriptor.name.toLowerCase()}`, | |||
) ?? foundReposNameMap.get(repoDescriptor.name.toLowerCase()); | |||
if (foundRepo != null) { | |||
await this.locateWorkspaceRepo(workspaceId, repoDescriptor, foundRepo); | |||
if (cancellation?.isCancellationRequested) return; | |||
} | |||
} | |||
} | |||
async locateWorkspaceRepo( | |||
workspaceId: string, | |||
descriptor: CloudWorkspaceRepositoryDescriptor | LocalWorkspaceRepositoryDescriptor, | |||
): Promise<void>; | |||
async locateWorkspaceRepo( | |||
workspaceId: string, | |||
descriptor: CloudWorkspaceRepositoryDescriptor | LocalWorkspaceRepositoryDescriptor, | |||
// eslint-disable-next-line @typescript-eslint/unified-signatures | |||
uri: Uri, | |||
): Promise<void>; | |||
async locateWorkspaceRepo( | |||
workspaceId: string, | |||
descriptor: CloudWorkspaceRepositoryDescriptor | LocalWorkspaceRepositoryDescriptor, | |||
// eslint-disable-next-line @typescript-eslint/unified-signatures | |||
repository: Repository, | |||
): Promise<void>; | |||
async locateWorkspaceRepo( | |||
workspaceId: string, | |||
descriptor: CloudWorkspaceRepositoryDescriptor | LocalWorkspaceRepositoryDescriptor, | |||
uriOrRepository?: Uri | Repository, | |||
): Promise<void> { | |||
let repo; | |||
if (uriOrRepository == null || uriOrRepository instanceof Uri) { | |||
let repoLocatedUri = uriOrRepository; | |||
if (repoLocatedUri == null) { | |||
repoLocatedUri = ( | |||
await window.showOpenDialog({ | |||
title: `Choose a location for ${descriptor.name}`, | |||
canSelectFiles: false, | |||
canSelectFolders: true, | |||
canSelectMany: false, | |||
}) | |||
)?.[0]; | |||
} | |||
if (repoLocatedUri == null) return; | |||
repo = await this.container.git.getOrOpenRepository(repoLocatedUri, { | |||
closeOnOpen: true, | |||
detectNested: false, | |||
}); | |||
if (repo == null) return; | |||
} else { | |||
repo = uriOrRepository; | |||
} | |||
const repoPath = repo.uri.fsPath; | |||
const remotes = await repo.getRemotes(); | |||
const remoteUrls: string[] = []; | |||
for (const remote of remotes) { | |||
const remoteUrl = remote.provider?.url({ type: RemoteResourceType.Repo }); | |||
if (remoteUrl != null) { | |||
remoteUrls.push(remoteUrl); | |||
} | |||
} | |||
for (const remoteUrl of remoteUrls) { | |||
await this.container.repositoryPathMapping.writeLocalRepoPath({ remoteUrl: remoteUrl }, repoPath); | |||
} | |||
if (descriptor.id != null) { | |||
await this.container.repositoryPathMapping.writeLocalRepoPath( | |||
{ | |||
remoteUrl: descriptor.url, | |||
repoInfo: { | |||
provider: descriptor.provider, | |||
owner: descriptor.provider_organization_id, | |||
repoName: descriptor.name, | |||
}, | |||
}, | |||
repoPath, | |||
); | |||
await this.updateCloudWorkspaceRepoLocalPath(workspaceId, descriptor.id, repoPath); | |||
} | |||
} | |||
async createCloudWorkspace(options?: { repos?: Repository[] }): Promise<void> { | |||
const input = window.createInputBox(); | |||
input.title = 'Create Cloud Workspace'; | |||
const quickpick = window.createQuickPick(); | |||
quickpick.title = 'Create Cloud Workspace'; | |||
const quickpickLabelToProviderType: { [label: string]: CloudWorkspaceProviderInputType } = { | |||
GitHub: CloudWorkspaceProviderInputType.GitHub, | |||
'GitHub Enterprise': CloudWorkspaceProviderInputType.GitHubEnterprise, | |||
// TODO add support for these in the future | |||
// GitLab: CloudWorkspaceProviderInputType.GitLab, | |||
// 'GitLab Self-Managed': CloudWorkspaceProviderInputType.GitLabSelfHosted, | |||
// Bitbucket: CloudWorkspaceProviderInputType.Bitbucket, | |||
// Azure: CloudWorkspaceProviderInputType.Azure, | |||
}; | |||
input.ignoreFocusOut = true; | |||
const disposables: Disposable[] = []; | |||
let workspaceName: string | undefined; | |||
let workspaceDescription = ''; | |||
let hostUrl: string | undefined; | |||
let azureOrganizationName: string | undefined; | |||
let azureProjectName: string | undefined; | |||
let workspaceProvider: CloudWorkspaceProviderInputType | undefined; | |||
let matchingProviderRepos: Repository[] = []; | |||
if (options?.repos != null && options.repos.length > 0) { | |||
// Currently only GitHub is supported. | |||
for (const repo of options.repos) { | |||
const repoRemotes = await repo.getRemotes({ filter: r => r.domain === 'github.com' }); | |||
if (repoRemotes.length === 0) { | |||
await window.showErrorMessage( | |||
`Only GitHub is supported for this operation. Please ensure all open repositories are hosted on GitHub.`, | |||
{ modal: true }, | |||
); | |||
return; | |||
} | |||
} | |||
workspaceProvider = CloudWorkspaceProviderInputType.GitHub; | |||
matchingProviderRepos = options.repos; | |||
} | |||
let includeReposResponse; | |||
try { | |||
workspaceName = await new Promise<string | undefined>(resolve => { | |||
disposables.push( | |||
input.onDidHide(() => resolve(undefined)), | |||
input.onDidAccept(() => { | |||
const value = input.value.trim(); | |||
if (!value) { | |||
input.validationMessage = 'Please enter a non-empty name for the workspace'; | |||
return; | |||
} | |||
resolve(value); | |||
}), | |||
); | |||
input.placeholder = 'Please enter a name for the new workspace'; | |||
input.prompt = 'Enter your workspace name'; | |||
input.show(); | |||
}); | |||
if (!workspaceName) return; | |||
workspaceDescription = await new Promise<string>(resolve => { | |||
disposables.push( | |||
input.onDidHide(() => resolve('')), | |||
input.onDidAccept(() => { | |||
const value = input.value.trim(); | |||
resolve(value || ''); | |||
}), | |||
); | |||
input.value = ''; | |||
input.title = 'Create Workspace'; | |||
input.placeholder = 'Please enter a description for the new workspace'; | |||
input.prompt = 'Enter your workspace description'; | |||
input.show(); | |||
}); | |||
if (workspaceProvider == null) { | |||
workspaceProvider = await new Promise<CloudWorkspaceProviderInputType | undefined>(resolve => { | |||
disposables.push( | |||
quickpick.onDidHide(() => resolve(undefined)), | |||
quickpick.onDidAccept(() => { | |||
if (quickpick.activeItems.length !== 0) { | |||
resolve(quickpickLabelToProviderType[quickpick.activeItems[0].label]); | |||
} | |||
}), | |||
); | |||
quickpick.placeholder = 'Please select a provider for the new workspace'; | |||
quickpick.items = Object.keys(quickpickLabelToProviderType).map(label => ({ label: label })); | |||
quickpick.canSelectMany = false; | |||
quickpick.show(); | |||
}); | |||
} | |||
if (!workspaceProvider) return; | |||
if ( | |||
workspaceProvider == CloudWorkspaceProviderInputType.GitHubEnterprise || | |||
workspaceProvider == CloudWorkspaceProviderInputType.GitLabSelfHosted | |||
) { | |||
hostUrl = await new Promise<string | undefined>(resolve => { | |||
disposables.push( | |||
input.onDidHide(() => resolve(undefined)), | |||
input.onDidAccept(() => { | |||
const value = input.value.trim(); | |||
if (!value) { | |||
input.validationMessage = 'Please enter a non-empty host URL for the workspace'; | |||
return; | |||
} | |||
resolve(value); | |||
}), | |||
); | |||
input.value = ''; | |||
input.placeholder = 'Please enter a host URL for the new workspace'; | |||
input.prompt = 'Enter your workspace host URL'; | |||
input.show(); | |||
}); | |||
if (!hostUrl) return; | |||
} | |||
if (workspaceProvider == CloudWorkspaceProviderInputType.Azure) { | |||
azureOrganizationName = await new Promise<string | undefined>(resolve => { | |||
disposables.push( | |||
input.onDidHide(() => resolve(undefined)), | |||
input.onDidAccept(() => { | |||
const value = input.value.trim(); | |||
if (!value) { | |||
input.validationMessage = | |||
'Please enter a non-empty organization name for the workspace'; | |||
return; | |||
} | |||
resolve(value); | |||
}), | |||
); | |||
input.value = ''; | |||
input.placeholder = 'Please enter an organization name for the new workspace'; | |||
input.prompt = 'Enter your workspace organization name'; | |||
input.show(); | |||
}); | |||
if (!azureOrganizationName) return; | |||
azureProjectName = await new Promise<string | undefined>(resolve => { | |||
disposables.push( | |||
input.onDidHide(() => resolve(undefined)), | |||
input.onDidAccept(() => { | |||
const value = input.value.trim(); | |||
if (!value) { | |||
input.validationMessage = 'Please enter a non-empty project name for the workspace'; | |||
return; | |||
} | |||
resolve(value); | |||
}), | |||
); | |||
input.value = ''; | |||
input.placeholder = 'Please enter a project name for the new workspace'; | |||
input.prompt = 'Enter your workspace project name'; | |||
input.show(); | |||
}); | |||
if (!azureProjectName) return; | |||
} | |||
if (workspaceProvider != null && matchingProviderRepos.length === 0) { | |||
for (const repo of this.container.git.openRepositories) { | |||
const matchingRemotes = await repo.getRemotes({ | |||
filter: r => | |||
r.provider?.id === cloudWorkspaceProviderInputTypeToRemoteProviderId[workspaceProvider!], | |||
}); | |||
if (matchingRemotes.length) { | |||
matchingProviderRepos.push(repo); | |||
} | |||
} | |||
if (matchingProviderRepos.length) { | |||
includeReposResponse = await window.showInformationMessage( | |||
'Would you like to include your open repositories in the workspace?', | |||
{ modal: true }, | |||
{ title: 'Yes' }, | |||
{ title: 'No', isCloseAffordance: true }, | |||
); | |||
} | |||
} | |||
} finally { | |||
input.dispose(); | |||
quickpick.dispose(); | |||
disposables.forEach(d => void d.dispose()); | |||
} | |||
const createOptions = { | |||
name: workspaceName, | |||
description: workspaceDescription, | |||
provider: workspaceProvider, | |||
hostUrl: hostUrl, | |||
azureOrganizationName: azureOrganizationName, | |||
azureProjectName: azureProjectName, | |||
}; | |||
let createdProjectData: CloudWorkspaceData | null | undefined; | |||
try { | |||
const response = await this._workspacesApi.createWorkspace(createOptions); | |||
createdProjectData = response?.data?.create_project; | |||
} catch { | |||
return; | |||
} | |||
if (createdProjectData != null) { | |||
// Add the new workspace to cloud workspaces | |||
if (this._cloudWorkspaces == null) { | |||
this._cloudWorkspaces = []; | |||
} | |||
this._cloudWorkspaces?.push( | |||
new GKCloudWorkspace( | |||
createdProjectData.id, | |||
createdProjectData.name, | |||
createdProjectData.organization?.id, | |||
createdProjectData.provider as CloudWorkspaceProviderType, | |||
this._getCloudWorkspaceRepos, | |||
[], | |||
), | |||
); | |||
const newWorkspace = this.getCloudWorkspace(createdProjectData.id); | |||
if (newWorkspace != null && (includeReposResponse?.title === 'Yes' || options?.repos)) { | |||
const repoInputs: { repo: Repository; inputDescriptor: AddWorkspaceRepoDescriptor }[] = []; | |||
for (const repo of matchingProviderRepos) { | |||
const remote = (await repo.getRemote('origin')) || (await repo.getRemotes())?.[0]; | |||
const remoteOwnerAndName = remote?.provider?.path?.split('/') || remote?.path?.split('/'); | |||
if (remoteOwnerAndName == null || remoteOwnerAndName.length !== 2) continue; | |||
repoInputs.push({ | |||
repo: repo, | |||
inputDescriptor: { owner: remoteOwnerAndName[0], repoName: remoteOwnerAndName[1] }, | |||
}); | |||
} | |||
if (repoInputs.length) { | |||
let newRepoDescriptors: CloudWorkspaceRepositoryDescriptor[] = []; | |||
try { | |||
const response = await this._workspacesApi.addReposToWorkspace( | |||
newWorkspace.id, | |||
repoInputs.map(r => r.inputDescriptor), | |||
); | |||
if (response?.data.add_repositories_to_project == null) return; | |||
newRepoDescriptors = Object.values( | |||
response.data.add_repositories_to_project.provider_data, | |||
) as CloudWorkspaceRepositoryDescriptor[]; | |||
} catch { | |||
return; | |||
} | |||
if (newRepoDescriptors.length === 0) return; | |||
newWorkspace.addRepositories(newRepoDescriptors); | |||
for (const repoInput of repoInputs) { | |||
const repoDescriptor = newRepoDescriptors.find( | |||
r => r.name === repoInput.inputDescriptor.repoName, | |||
); | |||
if (!repoDescriptor) continue; | |||
await this.locateWorkspaceRepo(newWorkspace.id, repoDescriptor, repoInput.repo); | |||
} | |||
} | |||
} | |||
} | |||
} | |||
async deleteCloudWorkspace(workspaceId: string) { | |||
const confirmation = await window.showWarningMessage( | |||
`Are you sure you want to delete this workspace? This cannot be undone.`, | |||
{ modal: true }, | |||
{ title: 'Confirm' }, | |||
{ title: 'Cancel', isCloseAffordance: true }, | |||
); | |||
if (confirmation == null || confirmation.title == 'Cancel') return; | |||
try { | |||
const response = await this._workspacesApi.deleteWorkspace(workspaceId); | |||
if (response?.data?.delete_project?.id === workspaceId) { | |||
// Remove the workspace from the local workspace list. | |||
this._cloudWorkspaces = this._cloudWorkspaces?.filter(w => w.id !== workspaceId); | |||
} | |||
} catch {} | |||
} | |||
async addCloudWorkspaceRepo(workspaceId: string) { | |||
const workspace = this.getCloudWorkspace(workspaceId); | |||
if (workspace == null) return; | |||
const matchingProviderRepos = []; | |||
for (const repo of this.container.git.openRepositories) { | |||
const matchingRemotes = await repo.getRemotes({ | |||
filter: r => r.provider?.id === cloudWorkspaceProviderTypeToRemoteProviderId[workspace.provider], | |||
}); | |||
if (matchingRemotes.length) { | |||
matchingProviderRepos.push(repo); | |||
} | |||
} | |||
if (!matchingProviderRepos.length) { | |||
void window.showInformationMessage(`No open repositories found for provider ${workspace.provider}`); | |||
return; | |||
} | |||
const pick = await showRepositoryPicker( | |||
'Add Repository to Workspace', | |||
'Choose which repository to add to the workspace', | |||
matchingProviderRepos, | |||
); | |||
if (pick?.item == null) return; | |||
const repoPath = pick.repoPath; | |||
const repo = this.container.git.getRepository(repoPath); | |||
if (repo == null) return; | |||
const remote = (await repo.getRemote('origin')) || (await repo.getRemotes())?.[0]; | |||
const remoteOwnerAndName = remote?.provider?.path?.split('/') || remote?.path?.split('/'); | |||
if (remoteOwnerAndName == null || remoteOwnerAndName.length !== 2) return; | |||
let newRepoDescriptors: CloudWorkspaceRepositoryDescriptor[] = []; | |||
try { | |||
const response = await this._workspacesApi.addReposToWorkspace(workspaceId, [ | |||
{ owner: remoteOwnerAndName[0], repoName: remoteOwnerAndName[1] }, | |||
]); | |||
if (response?.data.add_repositories_to_project == null) return; | |||
newRepoDescriptors = Object.values( | |||
response.data.add_repositories_to_project.provider_data, | |||
) as CloudWorkspaceRepositoryDescriptor[]; | |||
} catch { | |||
return; | |||
} | |||
if (newRepoDescriptors.length === 0) return; | |||
workspace.addRepositories(newRepoDescriptors); | |||
await this.locateWorkspaceRepo(workspaceId, newRepoDescriptors[0], repo); | |||
} | |||
async removeCloudWorkspaceRepo(workspaceId: string, descriptor: CloudWorkspaceRepositoryDescriptor) { | |||
const workspace = this.getCloudWorkspace(workspaceId); | |||
if (workspace == null) return; | |||
const confirmation = await window.showWarningMessage( | |||
`Are you sure you want to remove ${descriptor.name} from this workspace? This cannot be undone.`, | |||
{ modal: true }, | |||
{ title: 'Confirm' }, | |||
{ title: 'Cancel', isCloseAffordance: true }, | |||
); | |||
if (confirmation == null || confirmation.title == 'Cancel') return; | |||
try { | |||
const response = await this._workspacesApi.removeReposFromWorkspace(workspaceId, [ | |||
{ owner: descriptor.provider_organization_id, repoName: descriptor.name }, | |||
]); | |||
if (response?.data.remove_repositories_from_project == null) return; | |||
workspace.removeRepositories([descriptor.name]); | |||
} catch {} | |||
} | |||
async resolveWorkspaceRepositoriesByName( | |||
workspaceId: string, | |||
workspaceType: WorkspaceType, | |||
): Promise<WorkspaceRepositoriesByName> { | |||
const workspaceRepositoriesByName: WorkspaceRepositoriesByName = new Map<string, Repository>(); | |||
const workspace: GKCloudWorkspace | GKLocalWorkspace | undefined = | |||
workspaceType === WorkspaceType.Cloud | |||
? this.getCloudWorkspace(workspaceId) | |||
: this.getLocalWorkspace(workspaceId); | |||
if (workspace?.repositories == null) return workspaceRepositoriesByName; | |||
for (const repository of workspace.repositories) { | |||
const currentRepositories = this.container.git.repositories; | |||
let repo: Repository | undefined = undefined; | |||
let repoId: string | undefined = undefined; | |||
let repoLocalPath: string | undefined = undefined; | |||
let repoRemoteUrl: string | undefined = undefined; | |||
let repoName: string | undefined = undefined; | |||
let repoProvider: string | undefined = undefined; | |||
let repoOwner: string | undefined = undefined; | |||
if (workspaceType === WorkspaceType.Local) { | |||
repoLocalPath = (repository as LocalWorkspaceRepositoryDescriptor).localPath; | |||
// repo name in this case is the last part of the path after splitting from the path separator | |||
repoName = (repository as LocalWorkspaceRepositoryDescriptor).name; | |||
for (const currentRepository of currentRepositories) { | |||
if (currentRepository.path.replaceAll('\\', '/') === repoLocalPath.replaceAll('\\', '/')) { | |||
repo = currentRepository; | |||
} | |||
} | |||
} else if (workspaceType === WorkspaceType.Cloud) { | |||
repoId = (repository as CloudWorkspaceRepositoryDescriptor).id; | |||
repoLocalPath = await this.getCloudWorkspaceRepoPath(workspaceId, repoId); | |||
repoRemoteUrl = (repository as CloudWorkspaceRepositoryDescriptor).url; | |||
repoName = (repository as CloudWorkspaceRepositoryDescriptor).name; | |||
repoProvider = (repository as CloudWorkspaceRepositoryDescriptor).provider; | |||
repoOwner = (repository as CloudWorkspaceRepositoryDescriptor).provider_organization_id; | |||
if (repoLocalPath == null) { | |||
const repoLocalPaths = await this.container.repositoryPathMapping.getLocalRepoPaths({ | |||
remoteUrl: repoRemoteUrl, | |||
repoInfo: { | |||
repoName: repoName, | |||
provider: repoProvider, | |||
owner: repoOwner, | |||
}, | |||
}); | |||
// TODO@ramint: The user should be able to choose which path to use if multiple available | |||
if (repoLocalPaths.length > 0) { | |||
repoLocalPath = repoLocalPaths[0]; | |||
} | |||
} | |||
for (const currentRepository of currentRepositories) { | |||
if ( | |||
repoLocalPath != null && | |||
currentRepository.path.replaceAll('\\', '/') === repoLocalPath.replaceAll('\\', '/') | |||
) { | |||
repo = currentRepository; | |||
} | |||
} | |||
} | |||
// TODO: Add this logic back in once we think through virtual repository support a bit more. | |||
// We want to support virtual repositories not just as an automatic backup, but as a user choice. | |||
/*if (!repo) { | |||
let uri: Uri | undefined = undefined; | |||
if (repoLocalPath) { | |||
uri = Uri.file(repoLocalPath); | |||
} else if (repoRemoteUrl) { | |||
uri = Uri.parse(repoRemoteUrl); | |||
uri = uri.with({ | |||
scheme: Schemes.Virtual, | |||
authority: encodeAuthority<GitHubAuthorityMetadata>('github'), | |||
path: uri.path, | |||
}); | |||
} | |||
if (uri) { | |||
repo = await this.container.git.getOrOpenRepository(uri, { closeOnOpen: true }); | |||
} | |||
}*/ | |||
if (repoLocalPath != null && !repo) { | |||
repo = await this.container.git.getOrOpenRepository(Uri.file(repoLocalPath), { closeOnOpen: true }); | |||
} | |||
if (!repoName || !repo) { | |||
continue; | |||
} | |||
workspaceRepositoriesByName.set(repoName, repo); | |||
} | |||
return workspaceRepositoriesByName; | |||
} | |||
async saveAsCodeWorkspaceFile( | |||
workspaceId: string, | |||
workspaceType: WorkspaceType, | |||
options?: { open?: boolean }, | |||
): Promise<void> { | |||
const workspace: GKCloudWorkspace | GKLocalWorkspace | undefined = | |||
workspaceType === WorkspaceType.Cloud | |||
? this.getCloudWorkspace(workspaceId) | |||
: this.getLocalWorkspace(workspaceId); | |||
if (workspace?.repositories == null) return; | |||
const workspaceRepositoriesByName = await this.resolveWorkspaceRepositoriesByName(workspaceId, workspaceType); | |||
if (workspaceRepositoriesByName.size === 0) { | |||
void window.showErrorMessage('No repositories could be found in this workspace.', { modal: true }); | |||
return; | |||
} | |||
const workspaceFolderPaths: string[] = []; | |||
for (const repo of workspaceRepositoriesByName.values()) { | |||
if (!repo.virtual && repo.path != null) { | |||
workspaceFolderPaths.push(repo.path); | |||
} | |||
} | |||
if (workspaceFolderPaths.length < workspace.repositories.length) { | |||
const confirmation = await window.showWarningMessage( | |||
`Some repositories in this workspace could not be located locally. Do you want to continue?`, | |||
{ modal: true }, | |||
{ title: 'Continue' }, | |||
{ title: 'Cancel', isCloseAffordance: true }, | |||
); | |||
if (confirmation == null || confirmation.title == 'Cancel') return; | |||
} | |||
// Have the user choose a name and location for the new workspace file | |||
const newWorkspaceUri = await window.showSaveDialog({ | |||
defaultUri: Uri.file(`${workspace.name}.code-workspace`), | |||
filters: { | |||
'Code Workspace': ['code-workspace'], | |||
}, | |||
title: 'Choose a location for the new code workspace file', | |||
}); | |||
if (newWorkspaceUri == null) return; | |||
const created = await this._workspacesPathProvider.writeCodeWorkspaceFile( | |||
newWorkspaceUri, | |||
workspaceFolderPaths, | |||
{ workspaceId: workspaceId }, | |||
); | |||
if (!created) { | |||
void window.showErrorMessage('Could not create the new workspace file. Check logs for details'); | |||
return; | |||
} | |||
if (options?.open) { | |||
openWorkspace(newWorkspaceUri, { location: OpenWorkspaceLocation.NewWindow }); | |||
} | |||
} | |||
} | |||
// TODO: Add back in once we think through virtual repository support a bit more. | |||
/* function encodeAuthority<T>(scheme: string, metadata?: T): string { | |||
return `${scheme}${metadata != null ? `+${encodeUtf8Hex(JSON.stringify(metadata))}` : ''}`; | |||
} */ |
@ -0,0 +1,57 @@ | |||
import { ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri } from 'vscode'; | |||
import { unknownGitUri } from '../../git/gitUri'; | |||
import type { | |||
CloudWorkspaceRepositoryDescriptor, | |||
LocalWorkspaceRepositoryDescriptor, | |||
} from '../../plus/workspaces/models'; | |||
import type { WorkspacesView } from '../workspacesView'; | |||
import { ContextValues, ViewNode } from './viewNode'; | |||
export class WorkspaceMissingRepositoryNode extends ViewNode<WorkspacesView> { | |||
static key = ':workspaceMissingRepository'; | |||
static getId(workspaceId: string, repoName: string): string { | |||
return `gitlens${this.key}(${workspaceId}/${repoName})`; | |||
} | |||
constructor( | |||
view: WorkspacesView, | |||
parent: ViewNode, | |||
public readonly workspaceId: string, | |||
public readonly workspaceRepositoryDescriptor: | |||
| CloudWorkspaceRepositoryDescriptor | |||
| LocalWorkspaceRepositoryDescriptor, | |||
) { | |||
super(unknownGitUri, view, parent); | |||
} | |||
override toClipboard(): string { | |||
return this.name; | |||
} | |||
override get id(): string { | |||
return WorkspaceMissingRepositoryNode.getId(this.workspaceId, this.workspaceRepositoryDescriptor.name); | |||
} | |||
get name(): string { | |||
return this.workspaceRepositoryDescriptor.name; | |||
} | |||
getChildren(): ViewNode[] { | |||
return []; | |||
} | |||
getTreeItem(): TreeItem { | |||
const description = 'repo not found \u2022 please locate'; | |||
const icon: ThemeIcon = new ThemeIcon('question'); | |||
const item = new TreeItem(this.name, TreeItemCollapsibleState.None); | |||
item.id = this.id; | |||
item.description = description; | |||
item.tooltip = `${this.name} (missing)`; | |||
item.contextValue = ContextValues.WorkspaceMissingRepository; | |||
item.iconPath = icon; | |||
item.resourceUri = Uri.parse(`gitlens-view://workspaces/repository/missing`); | |||
return item; | |||
} | |||
} |
@ -0,0 +1,142 @@ | |||
import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode'; | |||
import { GitUri } from '../../git/gitUri'; | |||
import type { | |||
CloudWorkspaceRepositoryDescriptor, | |||
LocalWorkspaceRepositoryDescriptor, | |||
WorkspaceRepositoriesByName, | |||
} from '../../plus/workspaces/models'; | |||
import { GKCloudWorkspace, GKLocalWorkspace, WorkspaceType } from '../../plus/workspaces/models'; | |||
import type { WorkspacesView } from '../workspacesView'; | |||
import { MessageNode } from './common'; | |||
import { RepositoryNode } from './repositoryNode'; | |||
import { ContextValues, ViewNode } from './viewNode'; | |||
import { WorkspaceMissingRepositoryNode } from './workspaceMissingRepositoryNode'; | |||
export class WorkspaceNode extends ViewNode<WorkspacesView> { | |||
static key = ':workspace'; | |||
static getId(workspaceId: string): string { | |||
return `gitlens${this.key}(${workspaceId})`; | |||
} | |||
private _workspace: GKCloudWorkspace | GKLocalWorkspace; | |||
private _type: WorkspaceType; | |||
constructor( | |||
uri: GitUri, | |||
view: WorkspacesView, | |||
parent: ViewNode, | |||
public readonly workspace: GKCloudWorkspace | GKLocalWorkspace, | |||
) { | |||
super(uri, view, parent); | |||
this._workspace = workspace; | |||
this._type = workspace.type; | |||
} | |||
override get id(): string { | |||
return WorkspaceNode.getId(this._workspace.id ?? ''); | |||
} | |||
get name(): string { | |||
return this._workspace?.name ?? ''; | |||
} | |||
get workspaceId(): string { | |||
return this._workspace.id ?? ''; | |||
} | |||
get type(): WorkspaceType { | |||
return this._type; | |||
} | |||
private async getRepositories(): Promise< | |||
CloudWorkspaceRepositoryDescriptor[] | LocalWorkspaceRepositoryDescriptor[] | undefined | |||
> { | |||
return Promise.resolve(this._workspace?.repositories); | |||
} | |||
private _children: ViewNode[] | undefined; | |||
async getChildren(): Promise<ViewNode[]> { | |||
if (this._children == null) { | |||
this._children = []; | |||
let repositories: CloudWorkspaceRepositoryDescriptor[] | LocalWorkspaceRepositoryDescriptor[] | undefined; | |||
let repositoryInfo: string | undefined; | |||
if (this.workspace instanceof GKLocalWorkspace) { | |||
repositories = (await this.getRepositories()) ?? []; | |||
} else { | |||
const { repositories: repos, repositoriesInfo: repoInfo } = | |||
await this.workspace.getOrLoadRepositories(); | |||
repositories = repos; | |||
repositoryInfo = repoInfo; | |||
} | |||
if (repositories?.length === 0) { | |||
this._children.push(new MessageNode(this.view, this, 'No repositories in this workspace.')); | |||
return this._children; | |||
} else if (repositories?.length) { | |||
const reposByName: WorkspaceRepositoriesByName = | |||
await this.view.container.workspaces.resolveWorkspaceRepositoriesByName( | |||
this.workspaceId, | |||
this.type, | |||
); | |||
for (const repository of repositories) { | |||
const repo = reposByName.get(repository.name); | |||
if (!repo) { | |||
this._children.push( | |||
new WorkspaceMissingRepositoryNode(this.view, this, this.workspaceId, repository), | |||
); | |||
continue; | |||
} | |||
this._children.push( | |||
new RepositoryNode(GitUri.fromRepoPath(repo.path), this.view, this, repo, { | |||
workspace: this._workspace, | |||
workspaceRepoDescriptor: repository, | |||
}), | |||
); | |||
} | |||
} | |||
if (repositoryInfo != null) { | |||
this._children.push(new MessageNode(this.view, this, repositoryInfo)); | |||
} | |||
} | |||
return this._children; | |||
} | |||
getTreeItem(): TreeItem { | |||
const description = ''; | |||
// TODO@ramint Icon needs to change based on workspace type, and need a tooltip. | |||
const icon: ThemeIcon = new ThemeIcon(this._type == WorkspaceType.Cloud ? 'cloud' : 'folder'); | |||
const item = new TreeItem(this.name, TreeItemCollapsibleState.Collapsed); | |||
let contextValue = `${ContextValues.Workspace}`; | |||
if (this._type === WorkspaceType.Cloud) { | |||
contextValue += '+cloud'; | |||
} else { | |||
contextValue += '+local'; | |||
} | |||
item.id = this.id; | |||
item.description = description; | |||
item.contextValue = contextValue; | |||
item.iconPath = icon; | |||
item.tooltip = `${this.name}\n${ | |||
this._type === WorkspaceType.Cloud | |||
? `Cloud Workspace ${this._workspace.isShared() ? '(Shared)' : ''}` | |||
: 'Local Workspace' | |||
}${ | |||
this._workspace instanceof GKCloudWorkspace && this._workspace.provider != null | |||
? `\nProvider: ${this._workspace.provider}` | |||
: '' | |||
}`; | |||
item.resourceUri = undefined; | |||
return item; | |||
} | |||
override refresh() { | |||
this._children = undefined; | |||
} | |||
} |
@ -0,0 +1,67 @@ | |||
import { TreeItem, TreeItemCollapsibleState } from 'vscode'; | |||
import type { WorkspacesView } from '../workspacesView'; | |||
import { MessageNode } from './common'; | |||
import { RepositoriesNode } from './repositoriesNode'; | |||
import { ViewNode } from './viewNode'; | |||
import { WorkspaceNode } from './workspaceNode'; | |||
export class WorkspacesViewNode extends ViewNode<WorkspacesView> { | |||
static key = ':workspaces'; | |||
static getId(): string { | |||
return `gitlens${this.key}`; | |||
} | |||
private _children: (WorkspaceNode | MessageNode | RepositoriesNode)[] | undefined; | |||
override get id(): string { | |||
return WorkspacesViewNode.getId(); | |||
} | |||
async getChildren(): Promise<ViewNode[]> { | |||
if (this._children == null) { | |||
const children: (WorkspaceNode | MessageNode | RepositoriesNode)[] = []; | |||
const { cloudWorkspaces, cloudWorkspaceInfo, localWorkspaces, localWorkspaceInfo } = | |||
await this.view.container.workspaces.getWorkspaces(); | |||
if (cloudWorkspaces.length || localWorkspaces.length) { | |||
children.push(new RepositoriesNode(this.view)); | |||
for (const workspace of cloudWorkspaces) { | |||
children.push(new WorkspaceNode(this.uri, this.view, this, workspace)); | |||
} | |||
if (cloudWorkspaceInfo != null) { | |||
children.push(new MessageNode(this.view, this, cloudWorkspaceInfo)); | |||
} | |||
for (const workspace of localWorkspaces) { | |||
children.push(new WorkspaceNode(this.uri, this.view, this, workspace)); | |||
} | |||
if (cloudWorkspaces.length === 0 && cloudWorkspaceInfo == null) { | |||
children.push(new MessageNode(this.view, this, 'No cloud workspaces found.')); | |||
} | |||
if (localWorkspaceInfo != null) { | |||
children.push(new MessageNode(this.view, this, localWorkspaceInfo)); | |||
} | |||
} | |||
this._children = children; | |||
} | |||
return this._children; | |||
} | |||
getTreeItem(): TreeItem { | |||
const item = new TreeItem('Workspaces', TreeItemCollapsibleState.Expanded); | |||
return item; | |||
} | |||
override refresh() { | |||
this._children = undefined; | |||
void this.getChildren(); | |||
} | |||
} |
@ -0,0 +1,209 @@ | |||
import type { Disposable, TreeViewVisibilityChangeEvent } from 'vscode'; | |||
import { ProgressLocation, window } from 'vscode'; | |||
import type { WorkspacesViewConfig } from '../config'; | |||
import type { Container } from '../container'; | |||
import { unknownGitUri } from '../git/gitUri'; | |||
import type { Repository } from '../git/models/repository'; | |||
import { ensurePlusFeaturesEnabled } from '../plus/subscription/utils'; | |||
import { WorkspaceType } from '../plus/workspaces/models'; | |||
import { SubscriptionState } from '../subscription'; | |||
import { openWorkspace, OpenWorkspaceLocation } from '../system/utils'; | |||
import type { RepositoriesNode } from './nodes/repositoriesNode'; | |||
import { RepositoryNode } from './nodes/repositoryNode'; | |||
import type { WorkspaceMissingRepositoryNode } from './nodes/workspaceMissingRepositoryNode'; | |||
import { WorkspaceNode } from './nodes/workspaceNode'; | |||
import { WorkspacesViewNode } from './nodes/workspacesViewNode'; | |||
import { ViewBase } from './viewBase'; | |||
import { registerViewCommand } from './viewCommands'; | |||
export class WorkspacesView extends ViewBase<WorkspacesViewNode, WorkspacesViewConfig> { | |||
protected readonly configKey = 'repositories'; | |||
private _workspacesChangedDisposable: Disposable; | |||
private _visibleDisposable: Disposable | undefined; | |||
constructor(container: Container) { | |||
super(container, 'gitlens.views.workspaces', 'Workspaces', 'workspaceView'); | |||
this._workspacesChangedDisposable = this.container.workspaces.onDidChangeWorkspaces(() => { | |||
void this.ensureRoot().triggerChange(true); | |||
}); | |||
} | |||
protected override onVisibilityChanged(e: TreeViewVisibilityChangeEvent): void { | |||
if (e.visible) { | |||
void this.updateDescription(); | |||
this._visibleDisposable?.dispose(); | |||
this._visibleDisposable = this.container.subscription.onDidChange(() => void this.updateDescription()); | |||
} else { | |||
this._visibleDisposable?.dispose(); | |||
this._visibleDisposable = undefined; | |||
} | |||
super.onVisibilityChanged(e); | |||
} | |||
override dispose() { | |||
this._workspacesChangedDisposable.dispose(); | |||
this._visibleDisposable?.dispose(); | |||
super.dispose(); | |||
} | |||
override get canSelectMany(): boolean { | |||
return false; | |||
} | |||
protected getRoot() { | |||
return new WorkspacesViewNode(unknownGitUri, this); | |||
} | |||
override async show(options?: { preserveFocus?: boolean | undefined }): Promise<void> { | |||
if (!(await ensurePlusFeaturesEnabled())) return; | |||
return super.show(options); | |||
} | |||
private async updateDescription() { | |||
const subscription = await this.container.subscription.getSubscription(); | |||
this.description = subscription.state === SubscriptionState.Paid ? undefined : '✨'; | |||
} | |||
override get canReveal(): boolean { | |||
return false; | |||
} | |||
protected registerCommands(): Disposable[] { | |||
void this.container.viewCommands; | |||
return [ | |||
registerViewCommand( | |||
this.getQualifiedCommand('refresh'), | |||
() => { | |||
this.container.workspaces.resetWorkspaces(); | |||
void this.ensureRoot().triggerChange(true); | |||
}, | |||
this, | |||
), | |||
registerViewCommand( | |||
this.getQualifiedCommand('convert'), | |||
async (node: RepositoriesNode) => { | |||
const repos: Repository[] = []; | |||
for (const child of node.getChildren()) { | |||
if (child instanceof RepositoryNode) { | |||
repos.push(child.repo); | |||
} | |||
} | |||
if (repos.length === 0) return; | |||
await this.container.workspaces.createCloudWorkspace({ repos: repos }); | |||
void this.ensureRoot().triggerChange(true); | |||
}, | |||
this, | |||
), | |||
registerViewCommand( | |||
this.getQualifiedCommand('create'), | |||
async () => { | |||
await this.container.workspaces.createCloudWorkspace(); | |||
void this.ensureRoot().triggerChange(true); | |||
}, | |||
this, | |||
), | |||
registerViewCommand( | |||
this.getQualifiedCommand('open'), | |||
async (node: WorkspaceNode) => { | |||
await this.container.workspaces.saveAsCodeWorkspaceFile(node.workspaceId, node.type, { | |||
open: true, | |||
}); | |||
}, | |||
this, | |||
), | |||
registerViewCommand( | |||
this.getQualifiedCommand('delete'), | |||
async (node: WorkspaceNode) => { | |||
await this.container.workspaces.deleteCloudWorkspace(node.workspaceId); | |||
void node.getParent()?.triggerChange(true); | |||
}, | |||
this, | |||
), | |||
registerViewCommand( | |||
this.getQualifiedCommand('locateRepo'), | |||
async (node: RepositoryNode | WorkspaceMissingRepositoryNode) => { | |||
const descriptor = node.workspaceRepositoryDescriptor; | |||
if (descriptor == null || node.workspaceId == null) return; | |||
await this.container.workspaces.locateWorkspaceRepo(node.workspaceId, descriptor); | |||
void node.getParent()?.triggerChange(true); | |||
}, | |||
this, | |||
), | |||
registerViewCommand( | |||
this.getQualifiedCommand('locateAllRepos'), | |||
async (node: WorkspaceNode) => { | |||
if (node.type !== WorkspaceType.Cloud) return; | |||
await window.withProgress( | |||
{ | |||
location: ProgressLocation.Notification, | |||
title: `Locating Repositories for '${node.workspace.name}'...`, | |||
cancellable: true, | |||
}, | |||
(_progress, token) => | |||
this.container.workspaces.locateAllCloudWorkspaceRepos(node.workspaceId, token), | |||
); | |||
void node.triggerChange(true); | |||
}, | |||
this, | |||
), | |||
registerViewCommand( | |||
this.getQualifiedCommand('openRepoNewWindow'), | |||
(node: RepositoryNode) => { | |||
const workspaceNode = node.getParent(); | |||
if (workspaceNode == null || !(workspaceNode instanceof WorkspaceNode)) { | |||
return; | |||
} | |||
openWorkspace(node.repo.uri, { location: OpenWorkspaceLocation.NewWindow }); | |||
}, | |||
this, | |||
), | |||
registerViewCommand( | |||
this.getQualifiedCommand('openRepoCurrentWindow'), | |||
(node: RepositoryNode) => { | |||
const workspaceNode = node.getParent(); | |||
if (workspaceNode == null || !(workspaceNode instanceof WorkspaceNode)) { | |||
return; | |||
} | |||
openWorkspace(node.repo.uri, { location: OpenWorkspaceLocation.CurrentWindow }); | |||
}, | |||
this, | |||
), | |||
registerViewCommand( | |||
this.getQualifiedCommand('openRepoWorkspace'), | |||
(node: RepositoryNode) => { | |||
const workspaceNode = node.getParent(); | |||
if (workspaceNode == null || !(workspaceNode instanceof WorkspaceNode)) { | |||
return; | |||
} | |||
openWorkspace(node.repo.uri, { location: OpenWorkspaceLocation.AddToWorkspace }); | |||
}, | |||
this, | |||
), | |||
registerViewCommand(this.getQualifiedCommand('addRepo'), async (node: WorkspaceNode) => { | |||
await this.container.workspaces.addCloudWorkspaceRepo(node.workspaceId); | |||
void node.getParent()?.triggerChange(true); | |||
}), | |||
registerViewCommand( | |||
this.getQualifiedCommand('removeRepo'), | |||
async (node: RepositoryNode | WorkspaceMissingRepositoryNode) => { | |||
const descriptor = node.workspaceRepositoryDescriptor; | |||
if (descriptor?.id == null || node.workspaceId == null) return; | |||
await this.container.workspaces.removeCloudWorkspaceRepo(node.workspaceId, descriptor); | |||
// TODO@axosoft-ramint Do we need the grandparent here? | |||
void node.getParent()?.getParent()?.triggerChange(true); | |||
}, | |||
), | |||
]; | |||
} | |||
} |