From fc8dd74865bee6245c2994493a27aeabb79006ae Mon Sep 17 00:00:00 2001 From: Ramin Tadayon <67011668+axosoft-ramint@users.noreply.github.com> Date: Tue, 6 Jun 2023 17:40:10 +0900 Subject: [PATCH] Workspaces sidebar view (#2650) * 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 --- package.json | 251 +++++- src/config.ts | 4 + src/constants.ts | 2 + src/container.ts | 23 +- .../repositoryWebPathMappingProvider.ts | 21 + .../workspacesWebPathMappingProvider.ts | 27 + src/env/browser/providers.ts | 10 + src/env/node/git/localGitProvider.ts | 74 +- .../repositoryLocalPathMappingProvider.ts | 111 +++ src/env/node/pathMapping/sharedGKDataFolder.ts | 80 ++ .../workspacesLocalPathMappingProvider.ts | 131 +++ src/env/node/providers.ts | 10 + src/git/gitProvider.ts | 5 +- src/git/gitProviderService.ts | 9 + src/git/remotes/remoteProvider.ts | 4 + src/pathMapping/models.ts | 11 + src/pathMapping/repositoryPathMappingProvider.ts | 13 + src/plus/github/githubGitProvider.ts | 7 +- src/plus/workspaces/models.ts | 557 +++++++++++++ src/plus/workspaces/workspacesApi.ts | 443 ++++++++++ .../workspaces/workspacesPathMappingProvider.ts | 16 + src/plus/workspaces/workspacesService.ts | 902 +++++++++++++++++++++ src/views/nodes/UncommittedFilesNode.ts | 9 +- src/views/nodes/branchNode.ts | 31 +- src/views/nodes/branchOrTagFolderNode.ts | 20 +- src/views/nodes/branchTrackingStatusFilesNode.ts | 13 +- src/views/nodes/branchTrackingStatusNode.ts | 8 +- src/views/nodes/branchesNode.ts | 10 +- src/views/nodes/compareBranchNode.ts | 7 +- src/views/nodes/contributorNode.ts | 5 +- src/views/nodes/contributorsNode.ts | 17 +- src/views/nodes/mergeStatusNode.ts | 12 +- src/views/nodes/rebaseStatusNode.ts | 12 +- src/views/nodes/reflogNode.ts | 19 +- src/views/nodes/remoteNode.ts | 10 +- src/views/nodes/remotesNode.ts | 21 +- src/views/nodes/repositoriesNode.ts | 13 +- src/views/nodes/repositoryNode.ts | 132 ++- src/views/nodes/stashNode.ts | 11 +- src/views/nodes/stashesNode.ts | 23 +- src/views/nodes/statusFilesNode.ts | 9 +- src/views/nodes/tagNode.ts | 16 +- src/views/nodes/tagsNode.ts | 14 +- src/views/nodes/viewNode.ts | 4 + src/views/nodes/workspaceMissingRepositoryNode.ts | 57 ++ src/views/nodes/workspaceNode.ts | 142 ++++ src/views/nodes/workspacesViewNode.ts | 67 ++ src/views/nodes/worktreeNode.ts | 16 +- src/views/nodes/worktreesNode.ts | 13 +- src/views/viewBase.ts | 37 +- src/views/viewDecorationProvider.ts | 24 + src/views/workspacesView.ts | 209 +++++ 52 files changed, 3556 insertions(+), 136 deletions(-) create mode 100644 src/env/browser/pathMapping/repositoryWebPathMappingProvider.ts create mode 100644 src/env/browser/pathMapping/workspacesWebPathMappingProvider.ts create mode 100644 src/env/node/pathMapping/repositoryLocalPathMappingProvider.ts create mode 100644 src/env/node/pathMapping/sharedGKDataFolder.ts create mode 100644 src/env/node/pathMapping/workspacesLocalPathMappingProvider.ts create mode 100644 src/pathMapping/models.ts create mode 100644 src/pathMapping/repositoryPathMappingProvider.ts create mode 100644 src/plus/workspaces/models.ts create mode 100644 src/plus/workspaces/workspacesApi.ts create mode 100644 src/plus/workspaces/workspacesPathMappingProvider.ts create mode 100644 src/plus/workspaces/workspacesService.ts create mode 100644 src/views/nodes/workspaceMissingRepositoryNode.ts create mode 100644 src/views/nodes/workspaceNode.ts create mode 100644 src/views/nodes/workspacesViewNode.ts create mode 100644 src/views/workspacesView.ts diff --git a/package.json b/package.json index ff28ad2..2e871b9 100644 --- a/package.json +++ b/package.json @@ -4221,6 +4221,24 @@ } }, { + "id": "gitlens.decorations.workspaceRepoMissingForegroundColor", + "description": "Specifies the decoration foreground color of workspace repos which are missing a local path", + "defaults": { + "dark": "#909090", + "light": "#949494", + "highContrast": "#d3d3d3" + } + }, + { + "id": "gitlens.decorations.workspaceRepoOpenForegroundColor", + "description": "Specifies the decoration foreground color of workspace repos which are open in the current workspace", + "defaults": { + "dark": "#35b15e", + "light": "#35b15e", + "highContrast": "#4dff88" + } + }, + { "id": "gitlens.decorations.worktreeView.hasUncommittedChangesForegroundColor", "description": "Specifies the decoration foreground color for worktrees that have uncommitted changes", "defaults": { @@ -6861,6 +6879,80 @@ "icon": "$(refresh)" }, { + "command": "gitlens.views.workspaces.convert", + "title": "Convert to GitKraken Workspace...", + "category": "GitLens", + "icon": "$(cloud-upload)", + "enablement": "!operationInProgress" + }, + { + "command": "gitlens.views.workspaces.create", + "title": "Create Workspace...", + "category": "GitLens", + "icon": "$(add)", + "enablement": "!operationInProgress" + }, + { + "command": "gitlens.views.workspaces.delete", + "title": "Delete Workspace...", + "category": "GitLens", + "icon": "$(trash)", + "enablement": "!operationInProgress" + }, + { + "command": "gitlens.views.workspaces.addRepo", + "title": "Add Repository to Workspace...", + "category": "GitLens", + "icon": "$(add)" + }, + { + "command": "gitlens.views.workspaces.locateRepo", + "title": "Locate Repository...", + "category": "GitLens", + "icon": "$(location)" + }, + { + "command": "gitlens.views.workspaces.locateAllRepos", + "title": "Locate All Repositories for this Workspace...", + "category": "GitLens", + "icon": "$(location)" + }, + { + "command": "gitlens.views.workspaces.open", + "title": "Open As VS Code Workspace...", + "category": "GitLens", + "icon": "$(window)" + }, + { + "command": "gitlens.views.workspaces.openRepoNewWindow", + "title": "Open Repository in New Window", + "category": "GitLens", + "icon": "$(folder-opened)" + }, + { + "command": "gitlens.views.workspaces.openRepoCurrentWindow", + "title": "Open Repository in Current Window", + "category": "GitLens", + "icon": "$(folder-opened)" + }, + { + "command": "gitlens.views.workspaces.openRepoWorkspace", + "title": "Add Repository to Current VS Code Workspace", + "category": "GitLens" + }, + { + "command": "gitlens.views.workspaces.removeRepo", + "title": "Remove Repository from Workspace...", + "category": "GitLens", + "icon": "$(trash)" + }, + { + "command": "gitlens.views.workspaces.refresh", + "title": "Refresh", + "category": "GitLens", + "icon": "$(refresh)" + }, + { "command": "gitlens.views.worktrees.copy", "title": "Copy", "category": "GitLens" @@ -9280,6 +9372,54 @@ "when": "false" }, { + "command": "gitlens.views.workspaces.convert", + "when": "false" + }, + { + "command": "gitlens.views.workspaces.create", + "when": "false" + }, + { + "command": "gitlens.views.workspaces.delete", + "when": "false" + }, + { + "command": "gitlens.views.workspaces.addRepo", + "when": "false" + }, + { + "command": "gitlens.views.workspaces.locateRepo", + "when": "false" + }, + { + "command": "gitlens.views.workspaces.locateAllRepos", + "when": "false" + }, + { + "command": "gitlens.views.workspaces.open", + "when": "false" + }, + { + "command": "gitlens.views.workspaces.openRepoNewWindow", + "when": "false" + }, + { + "command": "gitlens.views.workspaces.openRepoCurrentWindow", + "when": "false" + }, + { + "command": "gitlens.views.workspaces.openRepoWorkspace", + "when": "false" + }, + { + "command": "gitlens.views.workspaces.removeRepo", + "when": "false" + }, + { + "command": "gitlens.views.workspaces.refresh", + "when": "false" + }, + { "command": "gitlens.views.worktrees.copy", "when": "false" }, @@ -10675,6 +10815,16 @@ "group": "navigation@99" }, { + "command": "gitlens.views.workspaces.create", + "when": "view =~ /^gitlens\\.views\\.workspaces/ && gitlens:plus", + "group": "navigation@1" + }, + { + "command": "gitlens.views.workspaces.refresh", + "when": "view =~ /^gitlens\\.views\\.workspaces/", + "group": "navigation@99" + }, + { "command": "gitlens.views.title.createWorktree", "when": "view =~ /^gitlens\\.views\\.worktrees/", "group": "navigation@10" @@ -10812,6 +10962,31 @@ ], "view/item/context": [ { + "command": "gitlens.plus.loginOrSignUp", + "when": "viewItem == gitlens:message:signin", + "group": "inline@1" + }, + { + "command": "gitlens.views.workspaces.convert", + "when": "viewItem =~ /gitlens:repositories\\b(?=.*?\\b\\+workspaces\\b)/ && gitlens:plus", + "group": "inline@1" + }, + { + "command": "gitlens.views.workspaces.locateAllRepos", + "when": "viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+cloud\\b)/", + "group": "inline@1" + }, + { + "command": "gitlens.views.workspaces.addRepo", + "when": "viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+cloud\\b)/", + "group": "inline@2" + }, + { + "command": "gitlens.views.workspaces.open", + "when": "viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+(cloud|local)\\b)/", + "group": "inline@3" + }, + { "command": "gitlens.views.switchToAnotherBranch", "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branches\\b/", "group": "inline@10" @@ -10838,6 +11013,26 @@ "alt": "gitlens.copyRemoteBranchesUrl" }, { + "command": "gitlens.views.workspaces.locateAllRepos", + "when": "viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+cloud\\b)/", + "group": "1_gitlens_actions@1" + }, + { + "command": "gitlens.views.workspaces.open", + "when": "viewItem =~ /gitlens:workspace/", + "group": "1_gitlens_actions@2" + }, + { + "command": "gitlens.views.workspaces.addRepo", + "when": "viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+cloud\\b)/", + "group": "1_gitlens_actions@3" + }, + { + "command": "gitlens.views.workspaces.delete", + "when": "viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+cloud\\b)/", + "group": "1_gitlens_actions@4" + }, + { "command": "gitlens.views.switchToAnotherBranch", "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:branches\\b/", "group": "1_gitlens_actions@1" @@ -11548,7 +11743,7 @@ }, { "command": "gitlens.views.star", - "when": "viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+starred\\b)/", + "when": "viewItem =~ /gitlens:repository\\b(?!.*?\\b\\+(starred|workspace)\\b)/", "group": "inline@99" }, { @@ -11965,6 +12160,42 @@ "group": "inline@1" }, { + "command": "gitlens.views.workspaces.locateRepo", + "when": "viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+workspace\\b)(?!.*?\\b\\+local\\b)/ || viewItem =~ /gitlens:workspaceMissingRepository\\b/", + "group": "inline@1" + }, + { + "command": "gitlens.views.workspaces.locateRepo", + "when": "viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+workspace\\b)(?!.*?\\b\\+local\\b)/ || viewItem =~ /gitlens:workspaceMissingRepository\\b/", + "group": "0_gitlens_actions@10" + }, + { + "command": "gitlens.views.workspaces.openRepoNewWindow", + "when": "viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+workspace\\b)/", + "group": "inline@100", + "alt": "gitlens.views.workspaces.openRepoCurrentWindow" + }, + { + "command": "gitlens.views.workspaces.openRepoNewWindow", + "when": "viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+workspace\\b)/", + "group": "0_gitlens_actions@11" + }, + { + "command": "gitlens.views.workspaces.openRepoCurrentWindow", + "when": "viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+workspace\\b)/", + "group": "0_gitlens_actions@12" + }, + { + "command": "gitlens.views.workspaces.openRepoWorkspace", + "when": "viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+workspace\\b)/", + "group": "0_gitlens_actions@13" + }, + { + "command": "gitlens.views.workspaces.removeRepo", + "when": "viewItem =~ /gitlens:repository\\b(?=.*?\\b\\+workspace\\b)(?!.*?\\b\\+local\\b)/ || viewItem =~ /gitlens:workspaceMissingRepository\\b/", + "group": "0_gitlens_actions@11" + }, + { "command": "gitlens.views.stash.rename", "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem == gitlens:stash", "group": "inline@98" @@ -13709,6 +13940,15 @@ "when": "!gitlens:hasVirtualFolders" }, { + "view": "gitlens.views.workspaces", + "contents": "Workspaces allow you and your organizations to store and sync collections of repositories across your devices and across the full line of GitKraken products.\n\nYou can create multiple workspaces, each of which can contain multiple repositories." + }, + { + "view": "gitlens.views.workspaces", + "contents": "[Start Free Pro Trial](command:gitlens.plus.loginOrSignUp)\n\nStart a free 7-day Pro trial to use GitKraken Workspaces, or [sign in](command:gitlens.plus.loginOrSignUp).\n☁️ Cloud workspaces require a free account while shared cloud workspaces require a trial or subscription.", + "when": "!gitlens:plus" + }, + { "view": "gitlens.views.worktrees", "contents": "Worktrees help you multitask by minimizing the context switching between branches, allowing you to easily work on different branches of a repository simultaneously.\n\nYou can create multiple working trees, each of which can be opened in individual windows or all together in a single workspace.", "when": "!gitlens:plus:required || gitlens:plus:state == 0" @@ -13751,6 +13991,15 @@ "visibility": "visible" }, { + "id": "gitlens.views.workspaces", + "name": "GitKraken Workspaces", + "when": "!gitlens:untrusted && !gitlens:hasVirtualFolders", + "contextualTitle": "GitLens", + "icon": "$(gitlens-worktrees-view)", + "initialSize": 2, + "visibility": "visible" + }, + { "id": "gitlens.views.contributors", "name": "Contributors", "when": "!gitlens:disabled", diff --git a/src/config.ts b/src/config.ts index a0e5c94..b8dce46 100644 --- a/src/config.ts +++ b/src/config.ts @@ -656,6 +656,7 @@ interface ViewsConfigs { searchAndCompare: SearchAndCompareViewConfig; stashes: StashesViewConfig; tags: TagsViewConfig; + workspaces: WorkspacesViewConfig; worktrees: WorktreesViewConfig; } @@ -813,6 +814,9 @@ export interface WorktreesViewConfig { showBranchComparison: false | ViewShowBranchComparison.Branch; } +// TODO@ramint +export type WorkspacesViewConfig = RepositoriesViewConfig; + export interface ViewsFilesConfig { compact: boolean; icon: 'status' | 'type'; diff --git a/src/constants.ts b/src/constants.ts index 23c3a56..cff0462 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -76,6 +76,8 @@ export type Colors = | `${typeof extensionPrefix}.decorations.modifiedForegroundColor` | `${typeof extensionPrefix}.decorations.renamedForegroundColor` | `${typeof extensionPrefix}.decorations.untrackedForegroundColor` + | `${typeof extensionPrefix}.decorations.workspaceRepoMissingForegroundColor` + | `${typeof extensionPrefix}.decorations.workspaceRepoOpenForegroundColor` | `${typeof extensionPrefix}.decorations.worktreeView.hasUncommittedChangesForegroundColor` | `${typeof extensionPrefix}.gutterBackgroundColor` | `${typeof extensionPrefix}.gutterForegroundColor` diff --git a/src/container.ts b/src/container.ts index c3731ae..8abb88d 100644 --- a/src/container.ts +++ b/src/container.ts @@ -1,6 +1,6 @@ import type { ConfigurationChangeEvent, Disposable, Event, ExtensionContext } from 'vscode'; import { EventEmitter, ExtensionMode } from 'vscode'; -import { getSupportedGitProviders } from '@env/providers'; +import { getSupportedGitProviders, getSupportedRepositoryPathMappingProvider } from '@env/providers'; import { AIProviderService } from './ai/aiProviderService'; import { Autolinks } from './annotations/autolinks'; import { FileAnnotationController } from './annotations/fileAnnotationController'; @@ -19,6 +19,7 @@ import { GitHubAuthenticationProvider } from './git/remotes/github'; import { GitLabAuthenticationProvider } from './git/remotes/gitlab'; import { RichRemoteProviderService } from './git/remotes/remoteProviderService'; import { LineHoverController } from './hovers/lineHoverController'; +import type { RepositoryPathMappingProvider } from './pathMapping/repositoryPathMappingProvider'; import { IntegrationAuthenticationService } from './plus/integrationAuthentication'; import { SubscriptionAuthenticationProvider } from './plus/subscription/authenticationProvider'; import { ServerConnection } from './plus/subscription/serverConnection'; @@ -32,6 +33,7 @@ import { } from './plus/webviews/graph/registration'; import { GraphStatusBarController } from './plus/webviews/graph/statusbar'; import { registerTimelineWebviewPanel, registerTimelineWebviewView } from './plus/webviews/timeline/registration'; +import { WorkspacesService } from './plus/workspaces/workspacesService'; import { StatusBarController } from './statusbar/statusBarController'; import { executeCommand } from './system/command'; import { configuration } from './system/configuration'; @@ -59,6 +61,7 @@ import { StashesView } from './views/stashesView'; import { TagsView } from './views/tagsView'; import { ViewCommands } from './views/viewCommands'; import { ViewFileDecorationProvider } from './views/viewDecorationProvider'; +import { WorkspacesView } from './views/workspacesView'; import { WorktreesView } from './views/worktreesView'; import { VslsController } from './vsls/vsls'; import { @@ -194,9 +197,11 @@ export class Container { (this._subscriptionAuthentication = new SubscriptionAuthenticationProvider(this, server)), ); this._disposables.push((this._subscription = new SubscriptionService(this, previousVersion))); + this._disposables.push((this._workspaces = new WorkspacesService(this, server))); this._disposables.push((this._git = new GitProviderService(this))); this._disposables.push(new GitFileSystemProvider(this)); + this._disposables.push((this._repositoryPathMapping = getSupportedRepositoryPathMappingProvider(this))); this._disposables.push((this._uri = new UriService(this))); @@ -249,6 +254,7 @@ export class Container { this._disposables.push((this._worktreesView = new WorktreesView(this))); this._disposables.push((this._contributorsView = new ContributorsView(this))); this._disposables.push((this._searchAndCompareView = new SearchAndCompareView(this))); + this._disposables.push((this._workspacesView = new WorkspacesView(this))); this._disposables.push((this._homeView = registerHomeWebviewView(this._webviews))); this._disposables.push((this._accountView = registerAccountWebviewView(this._webviews))); @@ -535,6 +541,11 @@ export class Container { return this._lineTracker; } + private readonly _repositoryPathMapping: RepositoryPathMappingProvider; + get repositoryPathMapping() { + return this._repositoryPathMapping; + } + private readonly _prerelease; get prerelease() { return this._prerelease; @@ -638,6 +649,16 @@ export class Container { return this._vsls; } + private _workspaces: WorkspacesService; + get workspaces() { + return this._workspaces; + } + + private _workspacesView: WorkspacesView; + get workspacesView() { + return this._workspacesView; + } + private readonly _worktreesView: WorktreesView; get worktreesView() { return this._worktreesView; diff --git a/src/env/browser/pathMapping/repositoryWebPathMappingProvider.ts b/src/env/browser/pathMapping/repositoryWebPathMappingProvider.ts new file mode 100644 index 0000000..455f2d5 --- /dev/null +++ b/src/env/browser/pathMapping/repositoryWebPathMappingProvider.ts @@ -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 { + return []; + } + + async writeLocalRepoPath( + _options: { remoteUrl?: string; repoInfo?: { provider: string; owner: string; repoName: string } }, + _localPath: string, + ): Promise {} +} diff --git a/src/env/browser/pathMapping/workspacesWebPathMappingProvider.ts b/src/env/browser/pathMapping/workspacesWebPathMappingProvider.ts new file mode 100644 index 0000000..5116d3b --- /dev/null +++ b/src/env/browser/pathMapping/workspacesWebPathMappingProvider.ts @@ -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 { + return undefined; + } + + async writeCloudWorkspaceDiskPathToMap( + _cloudWorkspaceId: string, + _repoId: string, + _repoLocalPath: string, + ): Promise {} + + async getLocalWorkspaceData(): Promise { + return { workspaces: {} }; + } + + async writeCodeWorkspaceFile( + _uri: Uri, + _workspaceRepoFilePaths: string[], + _options?: { workspaceId?: string }, + ): Promise { + return false; + } +} diff --git a/src/env/browser/providers.ts b/src/env/browser/providers.ts index 25ae9cb..35eb926 100644 --- a/src/env/browser/providers.ts +++ b/src/env/browser/providers.ts @@ -3,6 +3,8 @@ import { GitCommandOptions } from '../../git/commandOptions'; // Force import of GitHub since dynamic imports are not supported in the WebWorker ExtensionHost import { GitHubGitProvider } from '../../plus/github/githubGitProvider'; import { GitProvider } from '../../git/gitProvider'; +import { RepositoryWebPathMappingProvider } from './pathMapping/repositoryWebPathMappingProvider'; +import { WorkspacesWebPathMappingProvider } from './pathMapping/workspacesWebPathMappingProvider'; export function git(_options: GitCommandOptions, ..._args: any[]): Promise { return Promise.resolve(''); @@ -21,3 +23,11 @@ export function gitLogStreamTo( export async function getSupportedGitProviders(container: Container): Promise { return [new GitHubGitProvider(container)]; } + +export function getSupportedRepositoryPathMappingProvider(container: Container) { + return new RepositoryWebPathMappingProvider(container); +} + +export function getSupportedWorkspacesPathMappingProvider() { + return new WorkspacesWebPathMappingProvider(); +} diff --git a/src/env/node/git/localGitProvider.ts b/src/env/node/git/localGitProvider.ts index 53ad083..89c8f4a 100644 --- a/src/env/node/git/localGitProvider.ts +++ b/src/env/node/git/localGitProvider.ts @@ -388,7 +388,10 @@ export class LocalGitProvider implements GitProvider, Disposable { } @debug({ exit: true }) - async discoverRepositories(uri: Uri): Promise { + async discoverRepositories( + uri: Uri, + options?: { cancellation?: CancellationToken; depth?: number; silent?: boolean }, + ): Promise { if (uri.scheme !== Schemes.File) return []; try { @@ -398,22 +401,27 @@ export class LocalGitProvider implements GitProvider, Disposable { ) ?? true; const folder = workspace.getWorkspaceFolder(uri); - if (folder == null) return []; + if (folder == null && !options?.silent) return []; void (await this.ensureGit()); + if (options?.cancellation?.isCancellationRequested) return []; + const repositories = await this.repositorySearch( - folder, - autoRepositoryDetection === false || autoRepositoryDetection === 'openEditors' ? 0 : undefined, + folder ?? uri, + options?.depth ?? + (autoRepositoryDetection === false || autoRepositoryDetection === 'openEditors' ? 0 : undefined), + options?.cancellation, + options?.silent, ); - if (autoRepositoryDetection === true || autoRepositoryDetection === 'subFolders') { + if (!options?.silent && (autoRepositoryDetection === true || autoRepositoryDetection === 'subFolders')) { for (const repository of repositories) { void this.openScmRepository(repository.uri); } } - if (repositories.length > 0) { + if (!options?.silent && repositories.length > 0) { this._trackedPaths.clear(); } @@ -425,7 +433,7 @@ export class LocalGitProvider implements GitProvider, Disposable { void showGitMissingErrorMessage(); } else { const msg: string = ex?.message ?? ''; - if (msg) { + if (msg && !options?.silent) { void window.showErrorMessage(`Unable to initialize Git; ${msg}`); } } @@ -578,15 +586,30 @@ export class LocalGitProvider implements GitProvider, Disposable { @log({ args: false, singleLine: true, - prefix: (context, folder) => `${context.prefix}(${folder.uri.fsPath})`, + prefix: (context, folder) => `${context.prefix}(${(folder instanceof Uri ? folder : folder.uri).fsPath})`, exit: r => `returned ${r.length} repositories ${r.length !== 0 ? Logger.toLoggable(r) : ''}`, }) - private async repositorySearch(folder: WorkspaceFolder, depth?: number): Promise { + private async repositorySearch( + folderOrUri: Uri | WorkspaceFolder, + depth?: number, + cancellation?: CancellationToken, + silent?: boolean | undefined, + ): Promise { const scope = getLogScope(); + + let folder; + let rootUri; + if (folderOrUri instanceof Uri) { + rootUri = folderOrUri; + folder = workspace.getWorkspaceFolder(rootUri); + } else { + rootUri = folderOrUri.uri; + } + depth = depth ?? - configuration.get('advanced.repositorySearchDepth', folder.uri) ?? - configuration.getAny('git.repositoryScanMaxDepth', folder.uri, 1); + configuration.get('advanced.repositorySearchDepth', rootUri) ?? + configuration.getAny('git.repositoryScanMaxDepth', rootUri, 1); Logger.log(scope, `searching (depth=${depth})...`); @@ -595,7 +618,7 @@ export class LocalGitProvider implements GitProvider, Disposable { let rootPath; let canonicalRootPath; - const uri = await this.findRepositoryUri(folder.uri, true); + const uri = await this.findRepositoryUri(rootUri, true); if (uri != null) { rootPath = normalizePath(uri.fsPath); @@ -605,18 +628,18 @@ export class LocalGitProvider implements GitProvider, Disposable { } Logger.log(scope, `found root repository in '${uri.fsPath}'`); - repositories.push(...this.openRepository(folder, uri, true)); + repositories.push(...this.openRepository(folder, uri, true, undefined, silent)); } - if (depth <= 0) return repositories; + if (depth <= 0 || cancellation?.isCancellationRequested) return repositories; // Get any specified excludes -- this is a total hack, but works for some simple cases and something is better than nothing :) const excludes = new Set( - configuration.getAny('git.repositoryScanIgnoredFolders', folder.uri, []), + configuration.getAny('git.repositoryScanIgnoredFolders', rootUri, []), ); for (let [key, value] of Object.entries({ - ...configuration.getAny>('files.exclude', folder.uri, {}), - ...configuration.getAny>('search.exclude', folder.uri, {}), + ...configuration.getAny>('files.exclude', rootUri, {}), + ...configuration.getAny>('search.exclude', rootUri, {}), })) { if (!value) continue; if (key.includes('*.')) continue; @@ -629,7 +652,7 @@ export class LocalGitProvider implements GitProvider, Disposable { let repoPaths; try { - repoPaths = await this.repositorySearchCore(folder.uri.fsPath, depth, excludes); + repoPaths = await this.repositorySearchCore(rootUri.fsPath, depth, excludes, cancellation); } catch (ex) { const msg: string = ex?.toString() ?? ''; if (RepoSearchWarnings.doesNotExist.test(msg)) { @@ -665,7 +688,7 @@ export class LocalGitProvider implements GitProvider, Disposable { if (rp == null) continue; Logger.log(scope, `found repository in '${rp.fsPath}'`); - repositories.push(...this.openRepository(folder, rp, false)); + repositories.push(...this.openRepository(folder, rp, false, undefined, silent)); } return repositories; @@ -676,10 +699,13 @@ export class LocalGitProvider implements GitProvider, Disposable { root: string, depth: number, excludes: Set, + cancellation?: CancellationToken, repositories: string[] = [], ): Promise { const scope = getLogScope(); + if (cancellation?.isCancellationRequested) return Promise.resolve(repositories); + return new Promise((resolve, reject) => { readdir(root, { withFileTypes: true }, async (err, files) => { if (err != null) { @@ -696,11 +722,19 @@ export class LocalGitProvider implements GitProvider, Disposable { let f; for (f of files) { + if (cancellation?.isCancellationRequested) break; + if (f.name === '.git') { repositories.push(resolvePath(root, f.name)); } else if (depth >= 0 && f.isDirectory() && !excludes.has(f.name)) { try { - await this.repositorySearchCore(resolvePath(root, f.name), depth, excludes, repositories); + await this.repositorySearchCore( + resolvePath(root, f.name), + depth, + excludes, + cancellation, + repositories, + ); } catch (ex) { Logger.error(ex, scope, 'FAILED'); } diff --git a/src/env/node/pathMapping/repositoryLocalPathMappingProvider.ts b/src/env/node/pathMapping/repositoryLocalPathMappingProvider.ts new file mode 100644 index 0000000..c0bee4c --- /dev/null +++ b/src/env/node/pathMapping/repositoryLocalPathMappingProvider.ts @@ -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 { + await this.ensureLocalRepoDataMap(); + return this._localRepoDataMap ?? {}; + } + + async getLocalRepoPaths(options: { + remoteUrl?: string; + repoInfo?: { provider: string; owner: string; repoName: string }; + }): Promise { + 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 { + 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 { + 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 { + 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(); + } +} diff --git a/src/env/node/pathMapping/sharedGKDataFolder.ts b/src/env/node/pathMapping/sharedGKDataFolder.ts new file mode 100644 index 0000000..2a89f17 --- /dev/null +++ b/src/env/node/pathMapping/sharedGKDataFolder.ts @@ -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 { + 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 { + 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', + ), + ); +} diff --git a/src/env/node/pathMapping/workspacesLocalPathMappingProvider.ts b/src/env/node/pathMapping/workspacesLocalPathMappingProvider.ts new file mode 100644 index 0000000..85bb632 --- /dev/null +++ b/src/env/node/pathMapping/workspacesLocalPathMappingProvider.ts @@ -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 { + if (this._cloudWorkspaceRepoPathMap == null) { + await this.loadCloudWorkspaceRepoPathMap(); + } + } + + private async getCloudWorkspaceRepoPathMap(): Promise { + await this.ensureCloudWorkspaceRepoPathMap(); + return this._cloudWorkspaceRepoPathMap ?? {}; + } + + private async loadCloudWorkspaceRepoPathMap(): Promise { + 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 { + const cloudWorkspaceRepoPathMap = await this.getCloudWorkspaceRepoPathMap(); + return cloudWorkspaceRepoPathMap[cloudWorkspaceId]?.repoPaths[repoId]; + } + + async writeCloudWorkspaceDiskPathToMap( + cloudWorkspaceId: string, + repoId: string, + repoLocalPath: string, + ): Promise { + 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 { + // 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 { + 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; + } +} diff --git a/src/env/node/providers.ts b/src/env/node/providers.ts index e7404f8..e085df0 100644 --- a/src/env/node/providers.ts +++ b/src/env/node/providers.ts @@ -6,6 +6,8 @@ import { configuration } from '../../system/configuration'; import { Git } from './git/git'; import { LocalGitProvider } from './git/localGitProvider'; import { VslsGit, VslsGitProvider } from './git/vslsGitProvider'; +import { RepositoryLocalPathMappingProvider } from './pathMapping/repositoryLocalPathMappingProvider'; +import { WorkspacesLocalPathMappingProvider } from './pathMapping/workspacesLocalPathMappingProvider'; let gitInstance: Git | undefined; function ensureGit() { @@ -45,3 +47,11 @@ export async function getSupportedGitProviders(container: Container): Promise; - discoverRepositories(uri: Uri): Promise; + discoverRepositories( + uri: Uri, + options?: { cancellation?: CancellationToken; depth?: number; silent?: boolean }, + ): Promise; updateContext?(): void; openRepository( folder: WorkspaceFolder | undefined, diff --git a/src/git/gitProviderService.ts b/src/git/gitProviderService.ts index 7bb3721..d782f51 100644 --- a/src/git/gitProviderService.ts +++ b/src/git/gitProviderService.ts @@ -645,6 +645,15 @@ export class GitProviderService implements Disposable { } } + @log() + async findRepositories( + uri: Uri, + options?: { cancellation?: CancellationToken; depth?: number; silent?: boolean }, + ): Promise { + const { provider } = this.getProvider(uri); + return provider.discoverRepositories(uri, options); + } + private _subscription: Subscription | undefined; private async getSubscription(): Promise { return this._subscription ?? (this._subscription = await this.container.subscription.getSubscription()); diff --git a/src/git/remotes/remoteProvider.ts b/src/git/remotes/remoteProvider.ts index 5312754..3c1dc86 100644 --- a/src/git/remotes/remoteProvider.ts +++ b/src/git/remotes/remoteProvider.ts @@ -39,6 +39,10 @@ export abstract class RemoteProvider implements RemoteProviderReference { return 'remote'; } + get owner(): string | undefined { + return this.path.split('/')[0]; + } + abstract get id(): string; abstract get name(): string; diff --git a/src/pathMapping/models.ts b/src/pathMapping/models.ts new file mode 100644 index 0000000..dd71bcc --- /dev/null +++ b/src/pathMapping/models.ts @@ -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; +} diff --git a/src/pathMapping/repositoryPathMappingProvider.ts b/src/pathMapping/repositoryPathMappingProvider.ts new file mode 100644 index 0000000..85563ed --- /dev/null +++ b/src/pathMapping/repositoryPathMappingProvider.ts @@ -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; + + writeLocalRepoPath( + options: { remoteUrl?: string; repoInfo?: { provider: string; owner: string; repoName: string } }, + localPath: string, + ): Promise; +} diff --git a/src/plus/github/githubGitProvider.ts b/src/plus/github/githubGitProvider.ts index f7bd56f..c7b40fb 100644 --- a/src/plus/github/githubGitProvider.ts +++ b/src/plus/github/githubGitProvider.ts @@ -182,7 +182,10 @@ export class GitHubGitProvider implements GitProvider, Disposable { this._onDidChangeRepository.fire(e); } - async discoverRepositories(uri: Uri): Promise { + async discoverRepositories( + uri: Uri, + options?: { cancellation?: CancellationToken; depth?: number; silent?: boolean }, + ): Promise { if (!this.supportedSchemes.has(uri.scheme)) return []; try { @@ -190,7 +193,7 @@ export class GitHubGitProvider implements GitProvider, Disposable { const workspaceUri = remotehub.getVirtualWorkspaceUri(uri); if (workspaceUri == null) return []; - return this.openRepository(undefined, workspaceUri, true); + return this.openRepository(undefined, workspaceUri, true, undefined, options?.silent); } catch { return []; } diff --git a/src/plus/workspaces/models.ts b/src/plus/workspaces/models.ts new file mode 100644 index 0000000..d1d7e55 --- /dev/null +++ b/src/plus/workspaces/models.ts @@ -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; + +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, + 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 { + 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; +type ProjectGoalsSettings = Record; + +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; + pull_requests: CloudWorkspacePullRequestData[]; + issues: CloudWorkspaceIssue[]; + repository_members: CloudWorkspaceRepositoryMemberData[]; + milestones: CloudWorkspaceMilestone[]; + labels: CloudWorkspaceLabel[]; + issue_types: CloudWorkspaceIssueType[]; + provider_identity: ProviderCloudWorkspaceIdentity; + metrics: ProviderCloudWorkspaceMetrics; +} + +type ProviderCloudWorkspaceMetrics = Record; + +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; +type CloudWorkspaceLabel = Record; +type CloudWorkspaceIssueType = Record; + +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 { + total_count: number; + page_info: { + start_cursor: string; + end_cursor: string; + has_next_page: boolean; + }; + nodes: i[]; +} + +interface CloudWorkspaceFetchedConnection extends CloudWorkspaceConnection { + is_fetching: boolean; +} + +export interface WorkspacesResponse { + data: { + projects: CloudWorkspaceConnection; + }; +} + +export interface WorkspaceRepositoriesResponse { + data: { + project: { + provider_data: { + repositories: CloudWorkspaceConnection; + }; + }; + }; +} + +export interface WorkspacePullRequestsResponse { + data: { + project: { + provider_data: { + pull_requests: CloudWorkspaceFetchedConnection; + }; + }; + }; +} + +export interface WorkspacesWithPullRequestsResponse { + data: { + projects: { + nodes: { + provider_data: { + pull_requests: CloudWorkspaceFetchedConnection; + }; + }[]; + }; + }; + errors?: { + message: string; + path: unknown[]; + statusCode: number; + }[]; +} + +export interface WorkspaceIssuesResponse { + data: { + project: { + provider_data: { + issues: CloudWorkspaceFetchedConnection; + }; + }; + }; +} + +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; +}; diff --git a/src/plus/workspaces/workspacesApi.ts b/src/plus/workspaces/workspacesApi.ts new file mode 100644 index 0000000..affc86c --- /dev/null +++ b/src/plus/workspaces/workspacesApi.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/src/plus/workspaces/workspacesPathMappingProvider.ts b/src/plus/workspaces/workspacesPathMappingProvider.ts new file mode 100644 index 0000000..26c337c --- /dev/null +++ b/src/plus/workspaces/workspacesPathMappingProvider.ts @@ -0,0 +1,16 @@ +import type { Uri } from 'vscode'; +import type { LocalWorkspaceFileData } from './models'; + +export interface WorkspacesPathMappingProvider { + getCloudWorkspaceRepoPath(cloudWorkspaceId: string, repoId: string): Promise; + + writeCloudWorkspaceDiskPathToMap(cloudWorkspaceId: string, repoId: string, repoLocalPath: string): Promise; + + getLocalWorkspaceData(): Promise; + + writeCodeWorkspaceFile( + uri: Uri, + workspaceRepoFilePaths: string[], + options?: { workspaceId?: string }, + ): Promise; +} diff --git a/src/plus/workspaces/workspacesService.ts b/src/plus/workspaces/workspacesService.ts new file mode 100644 index 0000000..ebe4850 --- /dev/null +++ b/src/plus/workspaces/workspacesService.ts @@ -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 = new EventEmitter(); + get onDidChangeWorkspaces(): Event { + return this._onDidChangeWorkspaces.event; + } + private _disposable: Disposable; + + // TODO@ramint Add error handling/logging when this is used. + private readonly _getCloudWorkspaceRepos: (workspaceId: string) => Promise = + 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 { + 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 { + 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 { + 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 { + return this._workspacesPathProvider.getCloudWorkspaceRepoPath(cloudWorkspaceId, repoId); + } + + async updateCloudWorkspaceRepoLocalPath(workspaceId: string, repoId: string, localPath: string): Promise { + await this._workspacesPathProvider.writeCloudWorkspaceDiskPathToMap(workspaceId, repoId, localPath); + } + + async locateAllCloudWorkspaceRepos(workspaceId: string, cancellation?: CancellationToken): Promise { + 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(); + const foundReposNameMap = new Map(); + 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; + async locateWorkspaceRepo( + workspaceId: string, + descriptor: CloudWorkspaceRepositoryDescriptor | LocalWorkspaceRepositoryDescriptor, + // eslint-disable-next-line @typescript-eslint/unified-signatures + uri: Uri, + ): Promise; + async locateWorkspaceRepo( + workspaceId: string, + descriptor: CloudWorkspaceRepositoryDescriptor | LocalWorkspaceRepositoryDescriptor, + // eslint-disable-next-line @typescript-eslint/unified-signatures + repository: Repository, + ): Promise; + async locateWorkspaceRepo( + workspaceId: string, + descriptor: CloudWorkspaceRepositoryDescriptor | LocalWorkspaceRepositoryDescriptor, + uriOrRepository?: Uri | Repository, + ): Promise { + 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 { + 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(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(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(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(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(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(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 { + const workspaceRepositoriesByName: WorkspaceRepositoriesByName = new Map(); + 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('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 { + 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(scheme: string, metadata?: T): string { + return `${scheme}${metadata != null ? `+${encodeUtf8Hex(JSON.stringify(metadata))}` : ''}`; +} */ diff --git a/src/views/nodes/UncommittedFilesNode.ts b/src/views/nodes/UncommittedFilesNode.ts index 59a8810..01a48d4 100644 --- a/src/views/nodes/UncommittedFilesNode.ts +++ b/src/views/nodes/UncommittedFilesNode.ts @@ -19,8 +19,8 @@ import { ContextValues, ViewNode } from './viewNode'; export class UncommittedFilesNode extends ViewNode { static key = ':uncommitted-files'; - static getId(repoPath: string): string { - return `${RepositoryNode.getId(repoPath)}${this.key}`; + static getId(repoPath: string, workspaceId?: string): string { + return `${RepositoryNode.getId(repoPath, workspaceId)}${this.key}`; } readonly repoPath: string; @@ -37,13 +37,16 @@ export class UncommittedFilesNode extends ViewNode { readonly upstream?: string; }, public readonly range: string | undefined, + private readonly options?: { + workspaceId?: string; + }, ) { super(GitUri.fromRepoPath(status.repoPath), view, parent); this.repoPath = status.repoPath; } override get id(): string { - return UncommittedFilesNode.getId(this.repoPath); + return UncommittedFilesNode.getId(this.repoPath, this.options?.workspaceId); } getChildren(): ViewNode[] { diff --git a/src/views/nodes/branchNode.ts b/src/views/nodes/branchNode.ts index c2af776..65e26e7 100644 --- a/src/views/nodes/branchNode.ts +++ b/src/views/nodes/branchNode.ts @@ -39,8 +39,8 @@ type State = { export class BranchNode extends ViewRefNode implements PageableViewNode { static key = ':branch'; - static getId(repoPath: string, name: string, root: boolean): string { - return `${RepositoryNode.getId(repoPath)}${this.key}(${name})${root ? ':root' : ''}`; + static getId(repoPath: string, name: string, root: boolean, workspaceId?: string): string { + return `${RepositoryNode.getId(repoPath, workspaceId)}${this.key}(${name})${root ? ':root' : ''}`; } private readonly options: { @@ -52,6 +52,7 @@ export class BranchNode extends ViewRefNode, private readonly _key?: string, private readonly _expanded: boolean = false, + private readonly options?: { workspaceId?: string }, ) { super(GitUri.fromRepoPath(repoPath), view, parent); } @@ -33,7 +40,13 @@ export class BranchOrTagFolderNode extends ViewNode { } override get id(): string { - return BranchOrTagFolderNode.getId(this.repoPath, this._key, this.type, this.relativePath); + return BranchOrTagFolderNode.getId( + this.repoPath, + this._key, + this.type, + this.relativePath, + this.options?.workspaceId, + ); } getChildren(): ViewNode[] { @@ -56,6 +69,7 @@ export class BranchOrTagFolderNode extends ViewNode { folder, this._key, expanded, + this.options, ), ); continue; diff --git a/src/views/nodes/branchTrackingStatusFilesNode.ts b/src/views/nodes/branchTrackingStatusFilesNode.ts index 4ebf368..0894b2c 100644 --- a/src/views/nodes/branchTrackingStatusFilesNode.ts +++ b/src/views/nodes/branchTrackingStatusFilesNode.ts @@ -18,8 +18,15 @@ import { ContextValues, ViewNode } from './viewNode'; export class BranchTrackingStatusFilesNode extends ViewNode { static key = ':status-branch:files'; - static getId(repoPath: string, name: string, root: boolean, upstream: string, direction: string): string { - return `${BranchNode.getId(repoPath, name, root)}${this.key}(${upstream}|${direction})`; + static getId( + repoPath: string, + name: string, + root: boolean, + upstream: string, + direction: string, + workspaceId?: string, + ): string { + return `${BranchNode.getId(repoPath, name, root, workspaceId)}${this.key}(${upstream}|${direction})`; } readonly repoPath: string; @@ -32,6 +39,7 @@ export class BranchTrackingStatusFilesNode extends ViewNode { public readonly direction: 'ahead' | 'behind', // Specifies that the node is shown as a root private readonly root: boolean = false, + private readonly options?: { workspaceId?: string }, ) { super(GitUri.fromRepoPath(status.repoPath), view, parent); this.repoPath = status.repoPath; @@ -44,6 +52,7 @@ export class BranchTrackingStatusFilesNode extends ViewNode { this.root, this.status.upstream, this.direction, + this.options?.workspaceId, ); } diff --git a/src/views/nodes/branchTrackingStatusNode.ts b/src/views/nodes/branchTrackingStatusNode.ts index 5a6cb3d..c6f489b 100644 --- a/src/views/nodes/branchTrackingStatusNode.ts +++ b/src/views/nodes/branchTrackingStatusNode.ts @@ -35,12 +35,14 @@ export class BranchTrackingStatusNode extends ViewNode impleme root: boolean, upstream: string | undefined, upstreamType: string, + workspaceId?: string, ): string { - return `${BranchNode.getId(repoPath, name, root)}${this.key}(${upstream ?? ''}):${upstreamType}`; + return `${BranchNode.getId(repoPath, name, root, workspaceId)}${this.key}(${upstream ?? ''}):${upstreamType}`; } private readonly options: { showAheadCommits?: boolean; + workspaceId?: string; }; constructor( @@ -53,6 +55,7 @@ export class BranchTrackingStatusNode extends ViewNode impleme public readonly root: boolean = false, options?: { showAheadCommits?: boolean; + workspaceId?: string; }, ) { super(GitUri.fromRepoPath(status.repoPath), view, parent); @@ -67,6 +70,7 @@ export class BranchTrackingStatusNode extends ViewNode impleme this.root, this.status.upstream, this.upstreamType, + this.options?.workspaceId, ); } @@ -118,6 +122,7 @@ export class BranchTrackingStatusNode extends ViewNode impleme this.status as Required, this.upstreamType, this.root, + { workspaceId: this.options?.workspaceId }, ).getChildren()), ); } else { @@ -143,6 +148,7 @@ export class BranchTrackingStatusNode extends ViewNode impleme this.status as Required, this.upstreamType, this.root, + { workspaceId: this.options?.workspaceId }, ), ); } diff --git a/src/views/nodes/branchesNode.ts b/src/views/nodes/branchesNode.ts index 49e2ec4..a0838fc 100644 --- a/src/views/nodes/branchesNode.ts +++ b/src/views/nodes/branchesNode.ts @@ -15,8 +15,8 @@ import { ContextValues, ViewNode } from './viewNode'; export class BranchesNode extends ViewNode { static key = ':branches'; - static getId(repoPath: string): string { - return `${RepositoryNode.getId(repoPath)}${this.key}`; + static getId(repoPath: string, workspaceId?: string): string { + return `${RepositoryNode.getId(repoPath, workspaceId)}${this.key}`; } private _children: ViewNode[] | undefined; @@ -26,12 +26,13 @@ export class BranchesNode extends ViewNode { view: ViewsWithBranchesNode, protected override readonly parent: ViewNode, public readonly repo: Repository, + private readonly options?: { workspaceId?: string }, ) { super(uri, view, parent); } override get id(): string { - return BranchesNode.getId(this.repo.path); + return BranchesNode.getId(this.repo.path, this.options?.workspaceId); } get repoPath(): string { @@ -55,6 +56,7 @@ export class BranchesNode extends ViewNode { this.view instanceof RepositoriesView ? this.view.config.branches.showBranchComparison : this.view.config.showBranchComparison, + workspaceId: this.options?.workspaceId, }), ); if (this.view.config.branches.layout === ViewBranchesLayout.List) return branchNodes; @@ -79,6 +81,8 @@ export class BranchesNode extends ViewNode { undefined, hierarchy, 'branches', + undefined, + { workspaceId: this.options?.workspaceId }, ); this._children = root.getChildren(); } diff --git a/src/views/nodes/compareBranchNode.ts b/src/views/nodes/compareBranchNode.ts index ea213fd..73a9726 100644 --- a/src/views/nodes/compareBranchNode.ts +++ b/src/views/nodes/compareBranchNode.ts @@ -22,8 +22,8 @@ import { ContextValues, ViewNode } from './viewNode'; export class CompareBranchNode extends ViewNode { static key = ':compare-branch'; - static getId(repoPath: string, name: string, root: boolean): string { - return `${RepositoryNode.getId(repoPath)}${this.key}(${name})${root ? ':root' : ''}`; + static getId(repoPath: string, name: string, root: boolean, workspaceId?: string): string { + return `${RepositoryNode.getId(repoPath, workspaceId)}${this.key}(${name})${root ? ':root' : ''}`; } private _children: ViewNode[] | undefined; @@ -37,6 +37,7 @@ export class CompareBranchNode extends ViewNode implements name: string | undefined, email: string | undefined, username: string | undefined, + workspaceId?: string, ): string { - return `${RepositoryNode.getId(repoPath)}${this.key}(${name}|${email}|${username})`; + return `${RepositoryNode.getId(repoPath, workspaceId)}${this.key}(${name}|${email}|${username})`; } constructor( @@ -38,6 +39,7 @@ export class ContributorNode extends ViewNode implements all?: boolean; ref?: string; presence: Map | undefined; + workspaceId?: string; }, ) { super(uri, view, parent); @@ -53,6 +55,7 @@ export class ContributorNode extends ViewNode implements this.contributor.name, this.contributor.email, this.contributor.username, + this._options?.workspaceId, ); } diff --git a/src/views/nodes/contributorsNode.ts b/src/views/nodes/contributorsNode.ts index d5ec66e..260b506 100644 --- a/src/views/nodes/contributorsNode.ts +++ b/src/views/nodes/contributorsNode.ts @@ -14,8 +14,8 @@ import { ContextValues, ViewNode } from './viewNode'; export class ContributorsNode extends ViewNode { static key = ':contributors'; - static getId(repoPath: string): string { - return `${RepositoryNode.getId(repoPath)}${this.key}`; + static getId(repoPath: string, workspaceId?: string): string { + return `${RepositoryNode.getId(repoPath, workspaceId)}${this.key}`; } protected override splatted = true; @@ -27,12 +27,15 @@ export class ContributorsNode extends ViewNode { view: ViewsWithContributorsNode, protected override readonly parent: ViewNode, public readonly repo: Repository, + private readonly options?: { + workspaceId?: string; + }, ) { super(uri, view, parent); } override get id(): string { - return ContributorsNode.getId(this.repo.path); + return ContributorsNode.getId(this.repo.path, this.options?.workspaceId); } get repoPath(): string { @@ -63,7 +66,13 @@ export class ContributorsNode extends ViewNode { const presenceMap = await this.maybeGetPresenceMap(contributors); this._children = contributors.map( - c => new ContributorNode(this.uri, this.view, this, c, { all: all, ref: ref, presence: presenceMap }), + c => + new ContributorNode(this.uri, this.view, this, c, { + all: all, + ref: ref, + presence: presenceMap, + workspaceId: this.options?.workspaceId, + }), ); } diff --git a/src/views/nodes/mergeStatusNode.ts b/src/views/nodes/mergeStatusNode.ts index e475a18..cc9b1a1 100644 --- a/src/views/nodes/mergeStatusNode.ts +++ b/src/views/nodes/mergeStatusNode.ts @@ -18,8 +18,8 @@ import { ContextValues, ViewNode } from './viewNode'; export class MergeStatusNode extends ViewNode { static key = ':merge'; - static getId(repoPath: string, name: string, root: boolean): string { - return `${BranchNode.getId(repoPath, name, root)}${this.key}`; + static getId(repoPath: string, name: string, root: boolean, workspaceId?: string): string { + return `${BranchNode.getId(repoPath, name, root, workspaceId)}${this.key}`; } constructor( @@ -30,12 +30,18 @@ export class MergeStatusNode extends ViewNode { public readonly status: GitStatus | undefined, // Specifies that the node is shown as a root public readonly root: boolean, + private readonly options?: { workspaceId?: string }, ) { super(GitUri.fromRepoPath(mergeStatus.repoPath), view, parent); } override get id(): string { - return MergeStatusNode.getId(this.mergeStatus.repoPath, this.mergeStatus.current.name, this.root); + return MergeStatusNode.getId( + this.mergeStatus.repoPath, + this.mergeStatus.current.name, + this.root, + this.options?.workspaceId, + ); } get repoPath(): string { diff --git a/src/views/nodes/rebaseStatusNode.ts b/src/views/nodes/rebaseStatusNode.ts index 2d28bf2..a64f187 100644 --- a/src/views/nodes/rebaseStatusNode.ts +++ b/src/views/nodes/rebaseStatusNode.ts @@ -28,8 +28,8 @@ import { ContextValues, ViewNode, ViewRefNode } from './viewNode'; export class RebaseStatusNode extends ViewNode { static key = ':rebase'; - static getId(repoPath: string, name: string, root: boolean): string { - return `${BranchNode.getId(repoPath, name, root)}${this.key}`; + static getId(repoPath: string, name: string, root: boolean, workspaceId?: string): string { + return `${BranchNode.getId(repoPath, name, root, workspaceId)}${this.key}`; } constructor( @@ -40,12 +40,18 @@ export class RebaseStatusNode extends ViewNode { public readonly status: GitStatus | undefined, // Specifies that the node is shown as a root public readonly root: boolean, + private readonly options?: { workspaceId?: string }, ) { super(GitUri.fromRepoPath(rebaseStatus.repoPath), view, parent); } override get id(): string { - return RebaseStatusNode.getId(this.rebaseStatus.repoPath, this.rebaseStatus.incoming.name, this.root); + return RebaseStatusNode.getId( + this.rebaseStatus.repoPath, + this.rebaseStatus.incoming.name, + this.root, + this.options?.workspaceId, + ); } get repoPath(): string { diff --git a/src/views/nodes/reflogNode.ts b/src/views/nodes/reflogNode.ts index 5ab1b10..26fbe76 100644 --- a/src/views/nodes/reflogNode.ts +++ b/src/views/nodes/reflogNode.ts @@ -5,26 +5,35 @@ import type { Repository } from '../../git/models/repository'; import { gate } from '../../system/decorators/gate'; import { debug } from '../../system/decorators/log'; import type { RepositoriesView } from '../repositoriesView'; +import type { WorkspacesView } from '../workspacesView'; import { LoadMoreNode, MessageNode } from './common'; import { ReflogRecordNode } from './reflogRecordNode'; import { RepositoryNode } from './repositoryNode'; import type { PageableViewNode } from './viewNode'; import { ContextValues, ViewNode } from './viewNode'; -export class ReflogNode extends ViewNode implements PageableViewNode { +export class ReflogNode extends ViewNode implements PageableViewNode { static key = ':reflog'; - static getId(repoPath: string): string { - return `${RepositoryNode.getId(repoPath)}${this.key}`; + static getId(repoPath: string, workspaceId?: string): string { + return `${RepositoryNode.getId(repoPath, workspaceId)}${this.key}`; } private _children: ViewNode[] | undefined; - constructor(uri: GitUri, view: RepositoriesView, parent: ViewNode, public readonly repo: Repository) { + constructor( + uri: GitUri, + view: RepositoriesView | WorkspacesView, + parent: ViewNode, + public readonly repo: Repository, + private readonly options?: { + workspaceId?: string; + }, + ) { super(uri, view, parent); } override get id(): string { - return ReflogNode.getId(this.repo.path); + return ReflogNode.getId(this.repo.path, this.options?.workspaceId); } async getChildren(): Promise { diff --git a/src/views/nodes/remoteNode.ts b/src/views/nodes/remoteNode.ts index 428acdc..6b3cae4 100644 --- a/src/views/nodes/remoteNode.ts +++ b/src/views/nodes/remoteNode.ts @@ -16,8 +16,8 @@ import { ContextValues, ViewNode } from './viewNode'; export class RemoteNode extends ViewNode { static key = ':remote'; - static getId(repoPath: string, name: string, id: string): string { - return `${RepositoryNode.getId(repoPath)}${this.key}(${name}|${id})`; + static getId(repoPath: string, name: string, id: string, workspaceId?: string): string { + return `${RepositoryNode.getId(repoPath, workspaceId)}${this.key}(${name}|${id})`; } constructor( @@ -26,6 +26,7 @@ export class RemoteNode extends ViewNode { protected override readonly parent: ViewNode, public readonly remote: GitRemote, public readonly repo: Repository, + private readonly options?: { workspaceId?: string }, ) { super(uri, view, parent); } @@ -35,7 +36,7 @@ export class RemoteNode extends ViewNode { } override get id(): string { - return RemoteNode.getId(this.remote.repoPath, this.remote.name, this.remote.id); + return RemoteNode.getId(this.remote.repoPath, this.remote.name, this.remote.id, this.options?.workspaceId); } async getChildren(): Promise { @@ -52,6 +53,7 @@ export class RemoteNode extends ViewNode { new BranchNode(GitUri.fromRepoPath(this.uri.repoPath!, b.ref), this.view, this, b, false, { showComparison: false, showTracking: false, + workspaceId: this.options?.workspaceId, }), ); if (this.view.config.branches.layout === ViewBranchesLayout.List) return branchNodes; @@ -76,6 +78,8 @@ export class RemoteNode extends ViewNode { undefined, hierarchy, `remote(${this.remote.name})`, + undefined, + { workspaceId: this.options?.workspaceId }, ); const children = root.getChildren(); return children; diff --git a/src/views/nodes/remotesNode.ts b/src/views/nodes/remotesNode.ts index 481bd1e..1e341a3 100644 --- a/src/views/nodes/remotesNode.ts +++ b/src/views/nodes/remotesNode.ts @@ -11,18 +11,26 @@ import { ContextValues, ViewNode } from './viewNode'; export class RemotesNode extends ViewNode { static key = ':remotes'; - static getId(repoPath: string): string { - return `${RepositoryNode.getId(repoPath)}${this.key}`; + static getId(repoPath: string, workspaceId?: string): string { + return `${RepositoryNode.getId(repoPath, workspaceId)}${this.key}`; } private _children: ViewNode[] | undefined; - constructor(uri: GitUri, view: ViewsWithRemotesNode, parent: ViewNode, public readonly repo: Repository) { + constructor( + uri: GitUri, + view: ViewsWithRemotesNode, + parent: ViewNode, + public readonly repo: Repository, + private readonly options?: { + workspaceId?: string; + }, + ) { super(uri, view, parent); } override get id(): string { - return RemotesNode.getId(this.repo.path); + return RemotesNode.getId(this.repo.path, this.options?.workspaceId); } get repoPath(): string { @@ -36,7 +44,10 @@ export class RemotesNode extends ViewNode { return [new MessageNode(this.view, this, 'No remotes could be found')]; } - this._children = remotes.map(r => new RemoteNode(this.uri, this.view, this, r, this.repo)); + this._children = remotes.map( + r => + new RemoteNode(this.uri, this.view, this, r, this.repo, { workspaceId: this.options?.workspaceId }), + ); } return this._children; diff --git a/src/views/nodes/repositoriesNode.ts b/src/views/nodes/repositoriesNode.ts index edd8be4..a8aa659 100644 --- a/src/views/nodes/repositoriesNode.ts +++ b/src/views/nodes/repositoriesNode.ts @@ -7,6 +7,7 @@ import { debug } from '../../system/decorators/log'; import { debounce, szudzikPairing } from '../../system/function'; import { Logger } from '../../system/logger'; import type { ViewsWithRepositoriesNode } from '../viewBase'; +import { WorkspacesView } from '../workspacesView'; import { MessageNode } from './common'; import { RepositoryNode } from './repositoryNode'; import type { ViewNode } from './viewNode'; @@ -49,9 +50,17 @@ export class RepositoriesNode extends SubscribeableViewNode { static key = ':repository'; - static getId(repoPath: string): string { - return `gitlens${this.key}(${repoPath})`; + static getId(repoPath: string, workspaceId?: string): string { + return `gitlens${this.key}(${repoPath})${workspaceId != null ? `(${workspaceId})` : ''}`; } private _children: ViewNode[] | undefined; private _status: Promise; - constructor(uri: GitUri, view: ViewsWithRepositories, parent: ViewNode, public readonly repo: Repository) { + constructor( + uri: GitUri, + view: ViewsWithRepositories, + parent: ViewNode, + public readonly repo: Repository, + private readonly options?: { + workspace?: GKCloudWorkspace | GKLocalWorkspace; + workspaceRepoDescriptor: CloudWorkspaceRepositoryDescriptor | LocalWorkspaceRepositoryDescriptor; + }, + ) { super(uri, view, parent); this._status = this.repo.getStatus(); @@ -50,7 +64,18 @@ export class RepositoryNode extends SubscribeableViewNode } override get id(): string { - return RepositoryNode.getId(this.repo.path); + return RepositoryNode.getId(this.repo.path, this.options?.workspace?.id); + } + + get workspaceId(): string | undefined { + return this.options?.workspace?.id; + } + + get workspaceRepositoryDescriptor(): + | CloudWorkspaceRepositoryDescriptor + | LocalWorkspaceRepositoryDescriptor + | undefined { + return this.options?.workspaceRepoDescriptor; } async getChildren(): Promise { @@ -82,6 +107,7 @@ export class RepositoryNode extends SubscribeableViewNode branch, this.view.config.showBranchComparison, true, + { workspaceId: this.options?.workspace?.id }, ), ); } @@ -92,17 +118,31 @@ export class RepositoryNode extends SubscribeableViewNode ]); if (mergeStatus != null) { - children.push(new MergeStatusNode(this.view, this, branch, mergeStatus, status, true)); + children.push( + new MergeStatusNode(this.view, this, branch, mergeStatus, status, true, { + workspaceId: this.options?.workspace?.id, + }), + ); } else if (rebaseStatus != null) { - children.push(new RebaseStatusNode(this.view, this, branch, rebaseStatus, status, true)); + children.push( + new RebaseStatusNode(this.view, this, branch, rebaseStatus, status, true, { + workspaceId: this.options?.workspace?.id, + }), + ); } else if (this.view.config.showUpstreamStatus) { if (status.upstream) { if (!status.state.behind && !status.state.ahead) { - children.push(new BranchTrackingStatusNode(this.view, this, branch, status, 'same', true)); + children.push( + new BranchTrackingStatusNode(this.view, this, branch, status, 'same', true, { + workspaceId: this.options?.workspace?.id, + }), + ); } else { if (status.state.behind) { children.push( - new BranchTrackingStatusNode(this.view, this, branch, status, 'behind', true), + new BranchTrackingStatusNode(this.view, this, branch, status, 'behind', true, { + workspaceId: this.options?.workspace?.id, + }), ); } @@ -110,18 +150,27 @@ export class RepositoryNode extends SubscribeableViewNode children.push( new BranchTrackingStatusNode(this.view, this, branch, status, 'ahead', true, { showAheadCommits: true, + workspaceId: this.options?.workspace?.id, }), ); } } } else { - children.push(new BranchTrackingStatusNode(this.view, this, branch, status, 'none', true)); + children.push( + new BranchTrackingStatusNode(this.view, this, branch, status, 'none', true, { + workspaceId: this.options?.workspace?.id, + }), + ); } } if (this.view.config.includeWorkingTree && status.files.length !== 0) { const range = undefined; //status.upstream ? createRange(status.upstream, branch.ref) : undefined; - children.push(new StatusFilesNode(this.view, this, status, range)); + children.push( + new StatusFilesNode(this.view, this, status, range, { + workspaceId: this.options?.workspace?.id, + }), + ); } if (children.length !== 0 && !this.view.config.compact) { @@ -136,37 +185,58 @@ export class RepositoryNode extends SubscribeableViewNode showCurrent: false, showStatus: false, showTracking: false, + workspaceId: this.options?.workspace?.id, }), ); } } if (this.view.config.showBranches) { - children.push(new BranchesNode(this.uri, this.view, this, this.repo)); + children.push( + new BranchesNode(this.uri, this.view, this, this.repo, { + workspaceId: this.options?.workspace?.id, + }), + ); } if (this.view.config.showRemotes) { - children.push(new RemotesNode(this.uri, this.view, this, this.repo)); + children.push( + new RemotesNode(this.uri, this.view, this, this.repo, { workspaceId: this.options?.workspace?.id }), + ); } if (this.view.config.showStashes && (await this.repo.supports(Features.Stashes))) { - children.push(new StashesNode(this.uri, this.view, this, this.repo)); + children.push( + new StashesNode(this.uri, this.view, this, this.repo, { workspaceId: this.options?.workspace?.id }), + ); } if (this.view.config.showTags) { - children.push(new TagsNode(this.uri, this.view, this, this.repo)); + children.push( + new TagsNode(this.uri, this.view, this, this.repo, { workspaceId: this.options?.workspace?.id }), + ); } if (this.view.config.showWorktrees && (await this.repo.supports(Features.Worktrees))) { - children.push(new WorktreesNode(this.uri, this.view, this, this.repo)); + children.push( + new WorktreesNode(this.uri, this.view, this, this.repo, { + workspaceId: this.options?.workspace?.id, + }), + ); } if (this.view.config.showContributors) { - children.push(new ContributorsNode(this.uri, this.view, this, this.repo)); + children.push( + new ContributorsNode(this.uri, this.view, this, this.repo, { + workspaceId: this.options?.workspace?.id, + }), + ); } if (this.view.config.showIncomingActivity && !this.repo.provider.virtual) { - children.push(new ReflogNode(this.uri, this.view, this, this.repo)); + children.push( + new ReflogNode(this.uri, this.view, this, this.repo, { workspaceId: this.options?.workspace?.id }), + ); } this._children = children; @@ -192,6 +262,17 @@ export class RepositoryNode extends SubscribeableViewNode if (this.repo.starred) { contextValue += '+starred'; } + if (this.options?.workspace) { + contextValue += '+workspace'; + if (this.options.workspace instanceof GKCloudWorkspace) { + contextValue += '+cloud'; + } else if (this.options.workspace instanceof GKLocalWorkspace) { + contextValue += '+local'; + } + } + if (this.repo.virtual) { + contextValue += '+virtual'; + } const status = await this._status; if (status != null) { @@ -252,7 +333,10 @@ export class RepositoryNode extends SubscribeableViewNode } } - const item = new TreeItem(label, TreeItemCollapsibleState.Expanded); + const item = new TreeItem( + label, + this.options?.workspace ? TreeItemCollapsibleState.Collapsed : TreeItemCollapsibleState.Expanded, + ); item.id = this.id; item.contextValue = contextValue; item.description = `${description ?? ''}${ @@ -263,6 +347,10 @@ export class RepositoryNode extends SubscribeableViewNode light: this.view.container.context.asAbsolutePath(`images/light/icon-repo${iconSuffix}.svg`), }; + if (this.options?.workspace && !this.repo.closed) { + item.resourceUri = Uri.parse(`gitlens-view://workspaces/repository/open`); + } + const markdown = new MarkdownString(tooltip, true); markdown.supportHtml = true; markdown.isTrusted = true; @@ -377,7 +465,11 @@ export class RepositoryNode extends SubscribeableViewNode } const range = undefined; //status.upstream ? createRange(status.upstream, status.sha) : undefined; - this._children.splice(index, deleteCount, new StatusFilesNode(this.view, this, status, range)); + this._children.splice( + index, + deleteCount, + new StatusFilesNode(this.view, this, status, range, { workspaceId: this.options?.workspace?.id }), + ); } else if (index !== -1) { this._children.splice(index, 1); } diff --git a/src/views/nodes/stashNode.ts b/src/views/nodes/stashNode.ts index 51a78a7..27bf6e5 100644 --- a/src/views/nodes/stashNode.ts +++ b/src/views/nodes/stashNode.ts @@ -17,15 +17,18 @@ import { ContextValues, ViewRefNode } from './viewNode'; export class StashNode extends ViewRefNode { static key = ':stash'; - static getId(repoPath: string, ref: string): string { - return `${RepositoryNode.getId(repoPath)}${this.key}(${ref})`; + static getId(repoPath: string, ref: string, workspaceId?: string): string { + return `${RepositoryNode.getId(repoPath, workspaceId)}${this.key}(${ref})`; } constructor( view: ViewsWithStashes, parent: ViewNode, public readonly commit: GitStashCommit, - private readonly options?: { icon?: boolean }, + private readonly options?: { + icon?: boolean; + workspaceId?: string; + }, ) { super(commit.getGitUri(), view, parent); } @@ -35,7 +38,7 @@ export class StashNode extends ViewRefNode } override get id(): string { - return StashNode.getId(this.commit.repoPath, this.commit.sha); + return StashNode.getId(this.commit.repoPath, this.commit.sha, this.options?.workspaceId); } get ref(): GitStashReference { diff --git a/src/views/nodes/stashesNode.ts b/src/views/nodes/stashesNode.ts index de41191..e019b84 100644 --- a/src/views/nodes/stashesNode.ts +++ b/src/views/nodes/stashesNode.ts @@ -12,18 +12,26 @@ import { ContextValues, ViewNode } from './viewNode'; export class StashesNode extends ViewNode { static key = ':stashes'; - static getId(repoPath: string): string { - return `${RepositoryNode.getId(repoPath)}${this.key}`; + static getId(repoPath: string, workspaceId?: string): string { + return `${RepositoryNode.getId(repoPath, workspaceId)}${this.key}`; } private _children: ViewNode[] | undefined; - constructor(uri: GitUri, view: ViewsWithStashesNode, parent: ViewNode, public readonly repo: Repository) { + constructor( + uri: GitUri, + view: ViewsWithStashesNode, + parent: ViewNode, + public readonly repo: Repository, + private readonly options?: { + workspaceId?: string; + }, + ) { super(uri, view, parent); } override get id(): string { - return StashesNode.getId(this.repo.path); + return StashesNode.getId(this.repo.path, this.options?.workspaceId); } async getChildren(): Promise { @@ -31,7 +39,12 @@ export class StashesNode extends ViewNode { const stash = await this.repo.getStash(); if (stash == null) return [new MessageNode(this.view, this, 'No stashes could be found.')]; - this._children = [...map(stash.commits.values(), c => new StashNode(this.view, this, c))]; + this._children = [ + ...map( + stash.commits.values(), + c => new StashNode(this.view, this, c, { workspaceId: this.options?.workspaceId }), + ), + ]; } return this._children; diff --git a/src/views/nodes/statusFilesNode.ts b/src/views/nodes/statusFilesNode.ts index 80ec55a..7a9b977 100644 --- a/src/views/nodes/statusFilesNode.ts +++ b/src/views/nodes/statusFilesNode.ts @@ -20,8 +20,8 @@ import { ContextValues, ViewNode } from './viewNode'; export class StatusFilesNode extends ViewNode { static key = ':status-files'; - static getId(repoPath: string): string { - return `${RepositoryNode.getId(repoPath)}${this.key}`; + static getId(repoPath: string, workspaceId?: string): string { + return `${RepositoryNode.getId(repoPath, workspaceId)}${this.key}`; } readonly repoPath: string; @@ -38,13 +38,16 @@ export class StatusFilesNode extends ViewNode { readonly upstream?: string; }, public readonly range: string | undefined, + private readonly options?: { + workspaceId?: string; + }, ) { super(GitUri.fromRepoPath(status.repoPath), view, parent); this.repoPath = status.repoPath; } override get id(): string { - return StatusFilesNode.getId(this.repoPath); + return StatusFilesNode.getId(this.repoPath, this.options?.workspaceId); } async getChildren(): Promise { diff --git a/src/views/nodes/tagNode.ts b/src/views/nodes/tagNode.ts index 40cc4c3..c760183 100644 --- a/src/views/nodes/tagNode.ts +++ b/src/views/nodes/tagNode.ts @@ -21,11 +21,19 @@ import { ContextValues, ViewRefNode } from './viewNode'; export class TagNode extends ViewRefNode implements PageableViewNode { static key = ':tag'; - static getId(repoPath: string, name: string): string { - return `${RepositoryNode.getId(repoPath)}${this.key}(${name})`; + static getId(repoPath: string, name: string, workspaceId?: string): string { + return `${RepositoryNode.getId(repoPath, workspaceId)}${this.key}(${name})`; } - constructor(uri: GitUri, view: ViewsWithTags, public override parent: ViewNode, public readonly tag: GitTag) { + constructor( + uri: GitUri, + view: ViewsWithTags, + public override parent: ViewNode, + public readonly tag: GitTag, + private readonly options?: { + workspaceId?: string; + }, + ) { super(uri, view, parent); } @@ -34,7 +42,7 @@ export class TagNode extends ViewRefNode impleme } override get id(): string { - return TagNode.getId(this.tag.repoPath, this.tag.name); + return TagNode.getId(this.tag.repoPath, this.tag.name, this.options?.workspaceId); } get label(): string { diff --git a/src/views/nodes/tagsNode.ts b/src/views/nodes/tagsNode.ts index c92f96e..500d82e 100644 --- a/src/views/nodes/tagsNode.ts +++ b/src/views/nodes/tagsNode.ts @@ -14,8 +14,8 @@ import { ContextValues, ViewNode } from './viewNode'; export class TagsNode extends ViewNode { static key = ':tags'; - static getId(repoPath: string): string { - return `${RepositoryNode.getId(repoPath)}${this.key}`; + static getId(repoPath: string, workspaceId?: string): string { + return `${RepositoryNode.getId(repoPath, workspaceId)}${this.key}`; } private _children: ViewNode[] | undefined; @@ -25,12 +25,13 @@ export class TagsNode extends ViewNode { view: ViewsWithTagsNode, protected override readonly parent: ViewNode, public readonly repo: Repository, + private readonly options?: { workspaceId?: string }, ) { super(uri, view, parent); } override get id(): string { - return TagsNode.getId(this.repo.path); + return TagsNode.getId(this.repo.path, this.options?.workspaceId); } get repoPath(): string { @@ -44,7 +45,10 @@ export class TagsNode extends ViewNode { // TODO@eamodio handle paging const tagNodes = tags.values.map( - t => new TagNode(GitUri.fromRepoPath(this.uri.repoPath!, t.ref), this.view, this, t), + t => + new TagNode(GitUri.fromRepoPath(this.uri.repoPath!, t.ref), this.view, this, t, { + workspaceId: this.options?.workspaceId, + }), ); if (this.view.config.branches.layout === ViewBranchesLayout.List) return tagNodes; @@ -64,6 +68,8 @@ export class TagsNode extends ViewNode { undefined, hierarchy, 'tags', + undefined, + { workspaceId: this.options?.workspaceId }, ); this._children = root.getChildren(); } diff --git a/src/views/nodes/viewNode.ts b/src/views/nodes/viewNode.ts index 45279df..65c1bd7 100644 --- a/src/views/nodes/viewNode.ts +++ b/src/views/nodes/viewNode.ts @@ -50,6 +50,7 @@ export const enum ContextValues { MergeConflictCurrentChanges = 'gitlens:merge-conflict:current', MergeConflictIncomingChanges = 'gitlens:merge-conflict:incoming', Message = 'gitlens:message', + MessageSignIn = 'gitlens:message:signin', Pager = 'gitlens:pager', PullRequest = 'gitlens:pullrequest', Rebase = 'gitlens:rebase', @@ -76,6 +77,9 @@ export const enum ContextValues { Tag = 'gitlens:tag', Tags = 'gitlens:tags', UncommittedFiles = 'gitlens:uncommitted:files', + Workspace = 'gitlens:workspace', + WorkspaceMissingRepository = 'gitlens:workspaceMissingRepository', + Workspaces = 'gitlens:workspaces', Worktree = 'gitlens:worktree', Worktrees = 'gitlens:worktrees', } diff --git a/src/views/nodes/workspaceMissingRepositoryNode.ts b/src/views/nodes/workspaceMissingRepositoryNode.ts new file mode 100644 index 0000000..dadb5cb --- /dev/null +++ b/src/views/nodes/workspaceMissingRepositoryNode.ts @@ -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 { + 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; + } +} diff --git a/src/views/nodes/workspaceNode.ts b/src/views/nodes/workspaceNode.ts new file mode 100644 index 0000000..bad9cdf --- /dev/null +++ b/src/views/nodes/workspaceNode.ts @@ -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 { + 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 { + 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; + } +} diff --git a/src/views/nodes/workspacesViewNode.ts b/src/views/nodes/workspacesViewNode.ts new file mode 100644 index 0000000..17703f8 --- /dev/null +++ b/src/views/nodes/workspacesViewNode.ts @@ -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 { + 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 { + 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(); + } +} diff --git a/src/views/nodes/worktreeNode.ts b/src/views/nodes/worktreeNode.ts index 819cd75..08ea028 100644 --- a/src/views/nodes/worktreeNode.ts +++ b/src/views/nodes/worktreeNode.ts @@ -32,8 +32,8 @@ type State = { export class WorktreeNode extends ViewNode { static key = ':worktree'; - static getId(repoPath: string, uri: Uri): string { - return `${RepositoryNode.getId(repoPath)}${this.key}(${uri.path})`; + static getId(repoPath: string, uri: Uri, workspaceId?: string): string { + return `${RepositoryNode.getId(repoPath, workspaceId)}${this.key}(${uri.path})`; } private _branch: GitBranch | undefined; @@ -43,6 +43,9 @@ export class WorktreeNode extends ViewNode { view: ViewsWithWorktrees, protected override readonly parent: ViewNode, public readonly worktree: GitWorktree, + private readonly options?: { + workspaceId?: string; + }, ) { super(uri, view, parent); } @@ -52,7 +55,7 @@ export class WorktreeNode extends ViewNode { } override get id(): string { - return WorktreeNode.getId(this.worktree.repoPath, this.worktree.uri); + return WorktreeNode.getId(this.worktree.repoPath, this.worktree.uri, this.options?.workspaceId); } get repoPath(): string { @@ -142,6 +145,7 @@ export class WorktreeNode extends ViewNode { branch, this.view.config.showBranchComparison, this.splatted, + { workspaceId: this.options?.workspaceId }, ), ); } @@ -178,7 +182,11 @@ export class WorktreeNode extends ViewNode { const status = getSettledValue(statusResult); if (status?.hasChanges) { - children.unshift(new UncommittedFilesNode(this.view, this, status, undefined)); + children.unshift( + new UncommittedFilesNode(this.view, this, status, undefined, { + workspaceId: this.options?.workspaceId, + }), + ); } this._children = children; diff --git a/src/views/nodes/worktreesNode.ts b/src/views/nodes/worktreesNode.ts index d7697e1..0d699b7 100644 --- a/src/views/nodes/worktreesNode.ts +++ b/src/views/nodes/worktreesNode.ts @@ -13,8 +13,8 @@ import { WorktreeNode } from './worktreeNode'; export class WorktreesNode extends ViewNode { static key = ':worktrees'; - static getId(repoPath: string): string { - return `${RepositoryNode.getId(repoPath)}${this.key}`; + static getId(repoPath: string, workspaceId?: string): string { + return `${RepositoryNode.getId(repoPath, workspaceId)}${this.key}`; } private _children: WorktreeNode[] | undefined; @@ -24,12 +24,15 @@ export class WorktreesNode extends ViewNode { view: ViewsWithWorktreesNode, protected override readonly parent: ViewNode, public readonly repo: Repository, + private readonly options?: { + workspaceId?: string; + }, ) { super(uri, view, parent); } override get id(): string { - return WorktreesNode.getId(this.repo.path); + return WorktreesNode.getId(this.repo.path, this.options?.workspaceId); } get repoPath(): string { @@ -44,7 +47,9 @@ export class WorktreesNode extends ViewNode { const worktrees = await this.repo.getWorktrees(); if (worktrees.length === 0) return [new MessageNode(this.view, this, 'No worktrees could be found.')]; - this._children = worktrees.map(c => new WorktreeNode(this.uri, this.view, this, c)); + this._children = worktrees.map( + c => new WorktreeNode(this.uri, this.view, this, c, { workspaceId: this.options?.workspaceId }), + ); } return this._children; diff --git a/src/views/viewBase.ts b/src/views/viewBase.ts index 454764d..b72133b 100644 --- a/src/views/viewBase.ts +++ b/src/views/viewBase.ts @@ -24,6 +24,7 @@ import type { TagsViewConfig, ViewsCommonConfig, ViewsConfigKeys, + WorkspacesViewConfig, WorktreesViewConfig, } from '../config'; import { viewsCommonConfigKeys, viewsConfigKeys } from '../config'; @@ -49,6 +50,7 @@ import type { RepositoriesView } from './repositoriesView'; import type { SearchAndCompareView } from './searchAndCompareView'; import type { StashesView } from './stashesView'; import type { TagsView } from './tagsView'; +import type { WorkspacesView } from './workspacesView'; import type { WorktreesView } from './worktreesView'; export type View = @@ -62,25 +64,29 @@ export type View = | SearchAndCompareView | StashesView | TagsView + | WorkspacesView | WorktreesView; -export type ViewsWithBranches = BranchesView | CommitsView | RemotesView | RepositoriesView; -export type ViewsWithBranchesNode = BranchesView | RepositoriesView; +export type ViewsWithBranches = BranchesView | CommitsView | RemotesView | RepositoriesView | WorkspacesView; +export type ViewsWithBranchesNode = BranchesView | RepositoriesView | WorkspacesView; export type ViewsWithCommits = Exclude; -export type ViewsWithContributors = ContributorsView | RepositoriesView; -export type ViewsWithContributorsNode = ContributorsView | RepositoriesView; -export type ViewsWithRemotes = RemotesView | RepositoriesView; -export type ViewsWithRemotesNode = RemotesView | RepositoriesView; -export type ViewsWithRepositories = RepositoriesView; -export type ViewsWithRepositoriesNode = RepositoriesView; -export type ViewsWithRepositoryFolders = Exclude; +export type ViewsWithContributors = ContributorsView | RepositoriesView | WorkspacesView; +export type ViewsWithContributorsNode = ContributorsView | RepositoriesView | WorkspacesView; +export type ViewsWithRemotes = RemotesView | RepositoriesView | WorkspacesView; +export type ViewsWithRemotesNode = RemotesView | RepositoriesView | WorkspacesView; +export type ViewsWithRepositories = RepositoriesView | WorkspacesView; +export type ViewsWithRepositoriesNode = RepositoriesView | WorkspacesView; +export type ViewsWithRepositoryFolders = Exclude< + View, + FileHistoryView | LineHistoryView | RepositoriesView | WorkspacesView +>; export type ViewsWithStashes = StashesView | ViewsWithCommits; -export type ViewsWithStashesNode = RepositoriesView | StashesView; -export type ViewsWithTags = RepositoriesView | TagsView; -export type ViewsWithTagsNode = RepositoriesView | TagsView; -export type ViewsWithWorkingTree = RepositoriesView | WorktreesView; -export type ViewsWithWorktrees = RepositoriesView | WorktreesView; -export type ViewsWithWorktreesNode = RepositoriesView | WorktreesView; +export type ViewsWithStashesNode = RepositoriesView | StashesView | WorkspacesView; +export type ViewsWithTags = RepositoriesView | TagsView | WorkspacesView; +export type ViewsWithTagsNode = RepositoriesView | TagsView | WorkspacesView; +export type ViewsWithWorkingTree = RepositoriesView | WorktreesView | WorkspacesView; +export type ViewsWithWorktrees = RepositoriesView | WorktreesView | WorkspacesView; +export type ViewsWithWorktreesNode = RepositoriesView | WorktreesView | WorkspacesView; export interface TreeViewNodeCollapsibleStateChangeEvent extends TreeViewExpansionEvent { state: TreeItemCollapsibleState; @@ -99,6 +105,7 @@ export abstract class ViewBase< | SearchAndCompareViewConfig | StashesViewConfig | TagsViewConfig + | WorkspacesViewConfig | WorktreesViewConfig, > implements TreeDataProvider, Disposable { diff --git a/src/views/viewDecorationProvider.ts b/src/views/viewDecorationProvider.ts index a8d8340..1f353fd 100644 --- a/src/views/viewDecorationProvider.ts +++ b/src/views/viewDecorationProvider.ts @@ -27,6 +27,10 @@ export class ViewFileDecorationProvider implements FileDecorationProvider, Dispo return this.provideRemoteDefaultDecoration(uri, token); } + if (uri.authority === 'workspaces') { + return this.provideWorkspaceDecoration(uri, token); + } + return undefined; }, }), @@ -38,6 +42,26 @@ export class ViewFileDecorationProvider implements FileDecorationProvider, Dispo this.disposable.dispose(); } + provideWorkspaceDecoration(uri: Uri, _token: CancellationToken): FileDecoration | undefined { + const [, type, status] = uri.path.split('/'); + if (type === 'repository') { + if (status === 'open') { + return { + badge: 'O', + color: new ThemeColor('gitlens.decorations.workspaceRepoOpenForegroundColor' satisfies Colors), + tooltip: 'Open', + }; + } else if (status === 'missing') { + return { + color: new ThemeColor('gitlens.decorations.workspaceRepoMissingForegroundColor' satisfies Colors), + tooltip: 'Missing', + }; + } + } + + return undefined; + } + provideFileDecoration(uri: Uri, token: CancellationToken): FileDecoration | undefined { if (uri.scheme === Schemes.Git) { const data = getQueryDataFromScmGitUri(uri); diff --git a/src/views/workspacesView.ts b/src/views/workspacesView.ts new file mode 100644 index 0000000..a1894a8 --- /dev/null +++ b/src/views/workspacesView.ts @@ -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 { + 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 { + 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); + }, + ), + ]; + } +}