Переглянути джерело

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 <eamodio@gmail.com>
main
Ramin Tadayon 1 рік тому
committed by GitHub
джерело
коміт
fc8dd74865
Не вдалося знайти GPG ключ що відповідає даному підпису Ідентифікатор GPG ключа: 4AEE18F83AFDEB23
52 змінених файлів з 3556 додано та 136 видалено
  1. +250
    -1
      package.json
  2. +4
    -0
      src/config.ts
  3. +2
    -0
      src/constants.ts
  4. +22
    -1
      src/container.ts
  5. +21
    -0
      src/env/browser/pathMapping/repositoryWebPathMappingProvider.ts
  6. +27
    -0
      src/env/browser/pathMapping/workspacesWebPathMappingProvider.ts
  7. +10
    -0
      src/env/browser/providers.ts
  8. +54
    -20
      src/env/node/git/localGitProvider.ts
  9. +111
    -0
      src/env/node/pathMapping/repositoryLocalPathMappingProvider.ts
  10. +80
    -0
      src/env/node/pathMapping/sharedGKDataFolder.ts
  11. +131
    -0
      src/env/node/pathMapping/workspacesLocalPathMappingProvider.ts
  12. +10
    -0
      src/env/node/providers.ts
  13. +4
    -1
      src/git/gitProvider.ts
  14. +9
    -0
      src/git/gitProviderService.ts
  15. +4
    -0
      src/git/remotes/remoteProvider.ts
  16. +11
    -0
      src/pathMapping/models.ts
  17. +13
    -0
      src/pathMapping/repositoryPathMappingProvider.ts
  18. +5
    -2
      src/plus/github/githubGitProvider.ts
  19. +557
    -0
      src/plus/workspaces/models.ts
  20. +443
    -0
      src/plus/workspaces/workspacesApi.ts
  21. +16
    -0
      src/plus/workspaces/workspacesPathMappingProvider.ts
  22. +902
    -0
      src/plus/workspaces/workspacesService.ts
  23. +6
    -3
      src/views/nodes/UncommittedFilesNode.ts
  24. +24
    -7
      src/views/nodes/branchNode.ts
  25. +17
    -3
      src/views/nodes/branchOrTagFolderNode.ts
  26. +11
    -2
      src/views/nodes/branchTrackingStatusFilesNode.ts
  27. +7
    -1
      src/views/nodes/branchTrackingStatusNode.ts
  28. +7
    -3
      src/views/nodes/branchesNode.ts
  29. +4
    -3
      src/views/nodes/compareBranchNode.ts
  30. +4
    -1
      src/views/nodes/contributorNode.ts
  31. +13
    -4
      src/views/nodes/contributorsNode.ts
  32. +9
    -3
      src/views/nodes/mergeStatusNode.ts
  33. +9
    -3
      src/views/nodes/rebaseStatusNode.ts
  34. +14
    -5
      src/views/nodes/reflogNode.ts
  35. +7
    -3
      src/views/nodes/remoteNode.ts
  36. +16
    -5
      src/views/nodes/remotesNode.ts
  37. +11
    -2
      src/views/nodes/repositoriesNode.ts
  38. +112
    -20
      src/views/nodes/repositoryNode.ts
  39. +7
    -4
      src/views/nodes/stashNode.ts
  40. +18
    -5
      src/views/nodes/stashesNode.ts
  41. +6
    -3
      src/views/nodes/statusFilesNode.ts
  42. +12
    -4
      src/views/nodes/tagNode.ts
  43. +10
    -4
      src/views/nodes/tagsNode.ts
  44. +4
    -0
      src/views/nodes/viewNode.ts
  45. +57
    -0
      src/views/nodes/workspaceMissingRepositoryNode.ts
  46. +142
    -0
      src/views/nodes/workspaceNode.ts
  47. +67
    -0
      src/views/nodes/workspacesViewNode.ts
  48. +12
    -4
      src/views/nodes/worktreeNode.ts
  49. +9
    -4
      src/views/nodes/worktreesNode.ts
  50. +22
    -15
      src/views/viewBase.ts
  51. +24
    -0
      src/views/viewDecorationProvider.ts
  52. +209
    -0
      src/views/workspacesView.ts

+ 250
- 1
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",

+ 4
- 0
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';

+ 2
- 0
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`

+ 22
- 1
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;

+ 21
- 0
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<string[]> {
return [];
}
async writeLocalRepoPath(
_options: { remoteUrl?: string; repoInfo?: { provider: string; owner: string; repoName: string } },
_localPath: string,
): Promise<void> {}
}

+ 27
- 0
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<string | undefined> {
return undefined;
}
async writeCloudWorkspaceDiskPathToMap(
_cloudWorkspaceId: string,
_repoId: string,
_repoLocalPath: string,
): Promise<void> {}
async getLocalWorkspaceData(): Promise<LocalWorkspaceFileData> {
return { workspaces: {} };
}
async writeCodeWorkspaceFile(
_uri: Uri,
_workspaceRepoFilePaths: string[],
_options?: { workspaceId?: string },
): Promise<boolean> {
return false;
}
}

+ 10
- 0
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<string | Buffer> {
return Promise.resolve('');
@ -21,3 +23,11 @@ export function gitLogStreamTo(
export async function getSupportedGitProviders(container: Container): Promise<GitProvider[]> {
return [new GitHubGitProvider(container)];
}
export function getSupportedRepositoryPathMappingProvider(container: Container) {
return new RepositoryWebPathMappingProvider(container);
}
export function getSupportedWorkspacesPathMappingProvider() {
return new WorkspacesWebPathMappingProvider();
}

+ 54
- 20
src/env/node/git/localGitProvider.ts Переглянути файл

@ -388,7 +388,10 @@ export class LocalGitProvider implements GitProvider, Disposable {
}
@debug({ exit: true })
async discoverRepositories(uri: Uri): Promise<Repository[]> {
async discoverRepositories(
uri: Uri,
options?: { cancellation?: CancellationToken; depth?: number; silent?: boolean },
): Promise<Repository[]> {
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<LocalGitProvider['repositorySearch']>({
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<Repository[]> {
private async repositorySearch(
folderOrUri: Uri | WorkspaceFolder,
depth?: number,
cancellation?: CancellationToken,
silent?: boolean | undefined,
): Promise<Repository[]> {
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<CoreGitConfiguration, number>('git.repositoryScanMaxDepth', folder.uri, 1);
configuration.get('advanced.repositorySearchDepth', rootUri) ??
configuration.getAny<CoreGitConfiguration, number>('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<string>(
configuration.getAny<CoreGitConfiguration, string[]>('git.repositoryScanIgnoredFolders', folder.uri, []),
configuration.getAny<CoreGitConfiguration, string[]>('git.repositoryScanIgnoredFolders', rootUri, []),
);
for (let [key, value] of Object.entries({
...configuration.getAny<CoreConfiguration, Record<string, boolean>>('files.exclude', folder.uri, {}),
...configuration.getAny<CoreConfiguration, Record<string, boolean>>('search.exclude', folder.uri, {}),
...configuration.getAny<CoreConfiguration, Record<string, boolean>>('files.exclude', rootUri, {}),
...configuration.getAny<CoreConfiguration, Record<string, boolean>>('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<string>,
cancellation?: CancellationToken,
repositories: string[] = [],
): Promise<string[]> {
const scope = getLogScope();
if (cancellation?.isCancellationRequested) return Promise.resolve(repositories);
return new Promise<string[]>((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');
}

+ 111
- 0
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<LocalRepoDataMap> {
await this.ensureLocalRepoDataMap();
return this._localRepoDataMap ?? {};
}
async getLocalRepoPaths(options: {
remoteUrl?: string;
repoInfo?: { provider: string; owner: string; repoName: string };
}): Promise<string[]> {
const paths: string[] = [];
if (options.remoteUrl != null) {
const remoteUrlPaths = await this._getLocalRepoPaths(options.remoteUrl);
if (remoteUrlPaths != null) {
paths.push(...remoteUrlPaths);
}
}
if (options.repoInfo != null) {
const { provider, owner, repoName } = options.repoInfo;
const repoInfoPaths = await this._getLocalRepoPaths(`${provider}/${owner}/${repoName}`);
if (repoInfoPaths != null) {
paths.push(...repoInfoPaths);
}
}
return paths;
}
private async _getLocalRepoPaths(key: string): Promise<string[] | undefined> {
const localRepoDataMap = await this.getLocalRepoDataMap();
return localRepoDataMap[key]?.paths;
}
private async loadLocalRepoDataMap() {
const localFileUri = getSharedRepositoryMappingFileUri();
try {
const data = await workspace.fs.readFile(localFileUri);
this._localRepoDataMap = (JSON.parse(data.toString()) ?? {}) as LocalRepoDataMap;
} catch (error) {
Logger.error(error, 'loadLocalRepoDataMap');
}
}
async writeLocalRepoPath(
options: { remoteUrl?: string; repoInfo?: { provider: string; owner: string; repoName: string } },
localPath: string,
): Promise<void> {
if (options.remoteUrl != null) {
await this._writeLocalRepoPath(options.remoteUrl, localPath);
}
if (
options.repoInfo?.provider != null &&
options.repoInfo?.owner != null &&
options.repoInfo?.repoName != null
) {
const { provider, owner, repoName } = options.repoInfo;
const key = `${provider}/${owner}/${repoName}`;
await this._writeLocalRepoPath(key, localPath);
}
}
private async _writeLocalRepoPath(key: string, localPath: string): Promise<void> {
if (!(await acquireSharedFolderWriteLock())) {
return;
}
await this.loadLocalRepoDataMap();
if (this._localRepoDataMap == null) {
this._localRepoDataMap = {};
}
if (this._localRepoDataMap[key] == null || this._localRepoDataMap[key].paths == null) {
this._localRepoDataMap[key] = { paths: [localPath] };
} else if (!this._localRepoDataMap[key].paths.includes(localPath)) {
this._localRepoDataMap[key].paths.push(localPath);
}
const localFileUri = getSharedRepositoryMappingFileUri();
const outputData = new Uint8Array(Buffer.from(JSON.stringify(this._localRepoDataMap)));
try {
await workspace.fs.writeFile(localFileUri, outputData);
} catch (error) {
Logger.error(error, 'writeLocalRepoPath');
}
await releaseSharedFolderWriteLock();
}
}

+ 80
- 0
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<boolean> {
const lockFileUri = getSharedLockFileUri();
let stat;
while (true) {
try {
stat = await workspace.fs.stat(lockFileUri);
} catch {
// File does not exist, so we can safely create it
break;
}
const currentTime = new Date().getTime();
if (currentTime - stat.ctime > 30000) {
// File exists, but the timestamp is older than 30 seconds, so we can safely remove it
break;
}
// File exists, and the timestamp is less than 30 seconds old, so we need to wait for it to be removed
await wait(100);
}
try {
// write the lockfile to the shared data folder
await workspace.fs.writeFile(lockFileUri, new Uint8Array(0));
} catch (error) {
Logger.error(error, 'acquireSharedFolderWriteLock');
return false;
}
return true;
}
export async function releaseSharedFolderWriteLock(): Promise<boolean> {
try {
const lockFileUri = getSharedLockFileUri();
await workspace.fs.delete(lockFileUri);
} catch (error) {
Logger.error(error, 'releaseSharedFolderWriteLock');
return false;
}
return true;
}
function getSharedLockFileUri() {
return Uri.file(path.join(os.homedir(), sharedGKDataFolder, 'lockfile'));
}
export function getSharedRepositoryMappingFileUri() {
return Uri.file(path.join(os.homedir(), sharedGKDataFolder, 'repoMapping.json'));
}
export function getSharedCloudWorkspaceMappingFileUri() {
return Uri.file(path.join(os.homedir(), sharedGKDataFolder, 'cloudWorkspaces.json'));
}
export function getSharedLocalWorkspaceMappingFileUri() {
return Uri.file(path.join(os.homedir(), sharedGKDataFolder, 'localWorkspaces.json'));
}
export function getSharedLegacyLocalWorkspaceMappingFileUri() {
return Uri.file(
path.join(
os.homedir(),
`${getPlatform() === 'windows' ? '/AppData/Roaming/' : ''}.gitkraken`,
'workspaces',
'workspaces.json',
),
);
}

+ 131
- 0
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<void> {
if (this._cloudWorkspaceRepoPathMap == null) {
await this.loadCloudWorkspaceRepoPathMap();
}
}
private async getCloudWorkspaceRepoPathMap(): Promise<CloudWorkspacesPathMap> {
await this.ensureCloudWorkspaceRepoPathMap();
return this._cloudWorkspaceRepoPathMap ?? {};
}
private async loadCloudWorkspaceRepoPathMap(): Promise<void> {
const localFileUri = getSharedCloudWorkspaceMappingFileUri();
try {
const data = await workspace.fs.readFile(localFileUri);
this._cloudWorkspaceRepoPathMap = (JSON.parse(data.toString())?.workspaces ?? {}) as CloudWorkspacesPathMap;
} catch (error) {
Logger.error(error, 'loadCloudWorkspaceRepoPathMap');
}
}
async getCloudWorkspaceRepoPath(cloudWorkspaceId: string, repoId: string): Promise<string | undefined> {
const cloudWorkspaceRepoPathMap = await this.getCloudWorkspaceRepoPathMap();
return cloudWorkspaceRepoPathMap[cloudWorkspaceId]?.repoPaths[repoId];
}
async writeCloudWorkspaceDiskPathToMap(
cloudWorkspaceId: string,
repoId: string,
repoLocalPath: string,
): Promise<void> {
if (!(await acquireSharedFolderWriteLock())) {
return;
}
await this.loadCloudWorkspaceRepoPathMap();
if (this._cloudWorkspaceRepoPathMap == null) {
this._cloudWorkspaceRepoPathMap = {};
}
if (this._cloudWorkspaceRepoPathMap[cloudWorkspaceId] == null) {
this._cloudWorkspaceRepoPathMap[cloudWorkspaceId] = { repoPaths: {} };
}
this._cloudWorkspaceRepoPathMap[cloudWorkspaceId].repoPaths[repoId] = repoLocalPath;
const localFileUri = getSharedCloudWorkspaceMappingFileUri();
const outputData = new Uint8Array(Buffer.from(JSON.stringify({ workspaces: this._cloudWorkspaceRepoPathMap })));
try {
await workspace.fs.writeFile(localFileUri, outputData);
} catch (error) {
Logger.error(error, 'writeCloudWorkspaceDiskPathToMap');
}
await releaseSharedFolderWriteLock();
}
// TODO@ramint: May want a file watcher on this file down the line
async getLocalWorkspaceData(): Promise<LocalWorkspaceFileData> {
// Read from file at path defined in the constant localWorkspaceDataFilePath
// If file does not exist, create it and return an empty object
let localFileUri;
let data;
try {
localFileUri = getSharedLocalWorkspaceMappingFileUri();
data = await workspace.fs.readFile(localFileUri);
return JSON.parse(data.toString()) as LocalWorkspaceFileData;
} catch (error) {
// Fall back to using legacy location for file
try {
localFileUri = getSharedLegacyLocalWorkspaceMappingFileUri();
data = await workspace.fs.readFile(localFileUri);
return JSON.parse(data.toString()) as LocalWorkspaceFileData;
} catch (error) {
Logger.error(error, 'getLocalWorkspaceData');
}
}
return { workspaces: {} };
}
async writeCodeWorkspaceFile(
uri: Uri,
workspaceRepoFilePaths: string[],
options?: { workspaceId?: string },
): Promise<boolean> {
let codeWorkspaceFileContents: CodeWorkspaceFileContents;
let data;
try {
data = await workspace.fs.readFile(uri);
codeWorkspaceFileContents = JSON.parse(data.toString()) as CodeWorkspaceFileContents;
} catch (error) {
codeWorkspaceFileContents = { folders: [], settings: {} };
}
codeWorkspaceFileContents.folders = workspaceRepoFilePaths.map(repoFilePath => ({ path: repoFilePath }));
if (options?.workspaceId != null) {
codeWorkspaceFileContents.settings['gitkraken.workspaceId'] = options.workspaceId;
}
const outputData = new Uint8Array(Buffer.from(JSON.stringify(codeWorkspaceFileContents)));
try {
await workspace.fs.writeFile(uri, outputData);
} catch (error) {
Logger.error(error, 'writeCodeWorkspaceFile');
return false;
}
return true;
}
}

+ 10
- 0
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
return providers;
}
export function getSupportedRepositoryPathMappingProvider(container: Container) {
return new RepositoryLocalPathMappingProvider(container);
}
export function getSupportedWorkspacesPathMappingProvider() {
return new WorkspacesLocalPathMappingProvider();
}

+ 4
- 1
src/git/gitProvider.ts Переглянути файл

@ -110,7 +110,10 @@ export interface GitProvider extends Disposable {
readonly descriptor: GitProviderDescriptor;
readonly supportedSchemes: Set<string>;
discoverRepositories(uri: Uri): Promise<Repository[]>;
discoverRepositories(
uri: Uri,
options?: { cancellation?: CancellationToken; depth?: number; silent?: boolean },
): Promise<Repository[]>;
updateContext?(): void;
openRepository(
folder: WorkspaceFolder | undefined,

+ 9
- 0
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<Repository[]> {
const { provider } = this.getProvider(uri);
return provider.discoverRepositories(uri, options);
}
private _subscription: Subscription | undefined;
private async getSubscription(): Promise<Subscription> {
return this._subscription ?? (this._subscription = await this.container.subscription.getSubscription());

+ 4
- 0
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;

+ 11
- 0
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;
}

+ 13
- 0
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<string[]>;
writeLocalRepoPath(
options: { remoteUrl?: string; repoInfo?: { provider: string; owner: string; repoName: string } },
localPath: string,
): Promise<void>;
}

+ 5
- 2
src/plus/github/githubGitProvider.ts Переглянути файл

@ -182,7 +182,10 @@ export class GitHubGitProvider implements GitProvider, Disposable {
this._onDidChangeRepository.fire(e);
}
async discoverRepositories(uri: Uri): Promise<Repository[]> {
async discoverRepositories(
uri: Uri,
options?: { cancellation?: CancellationToken; depth?: number; silent?: boolean },
): Promise<Repository[]> {
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 [];
}

+ 557
- 0
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<string, Repository>;
export interface GetWorkspacesResponse {
cloudWorkspaces: GKCloudWorkspace[];
localWorkspaces: GKLocalWorkspace[];
cloudWorkspaceInfo: string | undefined;
localWorkspaceInfo: string | undefined;
}
export interface LoadCloudWorkspacesResponse {
cloudWorkspaces: GKCloudWorkspace[] | undefined;
cloudWorkspaceInfo: string | undefined;
}
export interface LoadLocalWorkspacesResponse {
localWorkspaces: GKLocalWorkspace[] | undefined;
localWorkspaceInfo: string | undefined;
}
export interface GetCloudWorkspaceRepositoriesResponse {
repositories: CloudWorkspaceRepositoryDescriptor[] | undefined;
repositoriesInfo: string | undefined;
}
// Cloud Workspace types
export class GKCloudWorkspace {
private readonly _type: WorkspaceType = WorkspaceType.Cloud;
private readonly _id: string;
private readonly _organizationId: string | undefined;
private readonly _name: string;
private readonly _provider: CloudWorkspaceProviderType;
private _repositories: CloudWorkspaceRepositoryDescriptor[] | undefined;
constructor(
id: string,
name: string,
organizationId: string | undefined,
provider: CloudWorkspaceProviderType,
private readonly getReposFn: (workspaceId: string) => Promise<GetCloudWorkspaceRepositoriesResponse>,
repositories?: CloudWorkspaceRepositoryDescriptor[],
) {
this._id = id;
this._name = name;
this._organizationId = organizationId;
this._provider = provider;
this._repositories = repositories;
}
get type(): WorkspaceType {
return this._type;
}
get id(): string {
return this._id;
}
get name(): string {
return this._name;
}
get organization_id(): string | undefined {
return this._organizationId;
}
get provider(): CloudWorkspaceProviderType {
return this._provider;
}
get repositories(): CloudWorkspaceRepositoryDescriptor[] | undefined {
return this._repositories;
}
isShared(): boolean {
return this._organizationId != null;
}
getRepository(name: string): CloudWorkspaceRepositoryDescriptor | undefined {
return this._repositories?.find(r => r.name === name);
}
addRepositories(repositories: CloudWorkspaceRepositoryDescriptor[]): void {
if (this._repositories == null) {
this._repositories = repositories;
} else {
this._repositories = this._repositories.concat(repositories);
}
}
removeRepositories(repoNames: string[]): void {
if (this._repositories == null) return;
this._repositories = this._repositories.filter(r => !repoNames.includes(r.name));
}
async getOrLoadRepositories(): Promise<GetCloudWorkspaceRepositoriesResponse> {
if (this._repositories != null) return { repositories: this._repositories, repositoriesInfo: undefined };
const getResponse = await this.getReposFn(this._id);
if (getResponse.repositories != null) {
this._repositories = getResponse.repositories;
}
return getResponse;
}
}
export interface CloudWorkspaceRepositoryDescriptor {
id: string;
name: string;
description: string;
repository_id: string;
provider: string;
provider_organization_id: string;
provider_organization_name: string;
url: string;
}
export enum CloudWorkspaceProviderInputType {
GitHub = 'GITHUB',
GitHubEnterprise = 'GITHUB_ENTERPRISE',
GitLab = 'GITLAB',
GitLabSelfHosted = 'GITLAB_SELF_HOSTED',
Bitbucket = 'BITBUCKET',
Azure = 'AZURE',
}
export enum CloudWorkspaceProviderType {
GitHub = 'github',
GitHubEnterprise = 'github_enterprise',
GitLab = 'gitlab',
GitLabSelfHosted = 'gitlab_self_hosted',
Bitbucket = 'bitbucket',
Azure = 'azure',
}
export const cloudWorkspaceProviderTypeToRemoteProviderId = {
[CloudWorkspaceProviderType.Azure]: 'azure-devops',
[CloudWorkspaceProviderType.Bitbucket]: 'bitbucket',
[CloudWorkspaceProviderType.GitHub]: 'github',
[CloudWorkspaceProviderType.GitHubEnterprise]: 'github',
[CloudWorkspaceProviderType.GitLab]: 'gitlab',
[CloudWorkspaceProviderType.GitLabSelfHosted]: 'gitlab',
};
export const cloudWorkspaceProviderInputTypeToRemoteProviderId = {
[CloudWorkspaceProviderInputType.Azure]: 'azure-devops',
[CloudWorkspaceProviderInputType.Bitbucket]: 'bitbucket',
[CloudWorkspaceProviderInputType.GitHub]: 'github',
[CloudWorkspaceProviderInputType.GitHubEnterprise]: 'github',
[CloudWorkspaceProviderInputType.GitLab]: 'gitlab',
[CloudWorkspaceProviderInputType.GitLabSelfHosted]: 'gitlab',
};
export const defaultWorkspaceCount = 100;
export const defaultWorkspaceRepoCount = 100;
export interface CloudWorkspaceData {
id: string;
name: string;
description: string;
type: CloudWorkspaceType;
icon_url: string;
host_url: string;
status: string;
provider: string;
azure_organization_id: string;
azure_project: string;
created_date: Date;
updated_date: Date;
created_by: string;
updated_by: string;
members: CloudWorkspaceMember[];
organization: CloudWorkspaceOrganization;
issue_tracker: CloudWorkspaceIssueTracker;
settings: CloudWorkspaceSettings;
current_user: UserCloudWorkspaceSettings;
errors: string[];
provider_data: ProviderCloudWorkspaceData;
}
export type CloudWorkspaceType = 'GK_PROJECT' | 'GK_ORG_VELOCITY' | 'GK_CLI';
export interface CloudWorkspaceMember {
id: string;
role: string;
name: string;
username: string;
avatar_url: string;
}
interface CloudWorkspaceOrganization {
id: string;
team_ids: string[];
}
interface CloudWorkspaceIssueTracker {
provider: string;
settings: CloudWorkspaceIssueTrackerSettings;
}
interface CloudWorkspaceIssueTrackerSettings {
resource_id: string;
}
interface CloudWorkspaceSettings {
gkOrgVelocity: GKOrgVelocitySettings;
goals: ProjectGoalsSettings;
}
type GKOrgVelocitySettings = Record<string, unknown>;
type ProjectGoalsSettings = Record<string, unknown>;
interface UserCloudWorkspaceSettings {
project_id: string;
user_id: string;
tab_settings: UserCloudWorkspaceTabSettings;
}
interface UserCloudWorkspaceTabSettings {
issue_tracker: CloudWorkspaceIssueTracker;
}
export interface ProviderCloudWorkspaceData {
id: string;
provider_organization_id: string;
repository: CloudWorkspaceRepositoryData;
repositories: CloudWorkspaceConnection<CloudWorkspaceRepositoryData>;
pull_requests: CloudWorkspacePullRequestData[];
issues: CloudWorkspaceIssue[];
repository_members: CloudWorkspaceRepositoryMemberData[];
milestones: CloudWorkspaceMilestone[];
labels: CloudWorkspaceLabel[];
issue_types: CloudWorkspaceIssueType[];
provider_identity: ProviderCloudWorkspaceIdentity;
metrics: ProviderCloudWorkspaceMetrics;
}
type ProviderCloudWorkspaceMetrics = Record<string, unknown>;
interface ProviderCloudWorkspaceIdentity {
avatar_url: string;
id: string;
name: string;
username: string;
pat_organization: string;
is_using_pat: boolean;
scopes: string;
}
export interface Branch {
id: string;
node_id: string;
name: string;
commit: BranchCommit;
}
interface BranchCommit {
id: string;
url: string;
build_status: {
context: string;
state: string;
description: string;
};
}
export interface CloudWorkspaceRepositoryData {
id: string;
name: string;
description: string;
repository_id: string;
provider: string;
provider_organization_id: string;
provider_organization_name: string;
url: string;
default_branch: string;
branches: Branch[];
pull_requests: CloudWorkspacePullRequestData[];
issues: CloudWorkspaceIssue[];
members: CloudWorkspaceRepositoryMemberData[];
milestones: CloudWorkspaceMilestone[];
labels: CloudWorkspaceLabel[];
issue_types: CloudWorkspaceIssueType[];
possibly_deleted: boolean;
has_webhook: boolean;
}
interface CloudWorkspaceRepositoryMemberData {
avatar_url: string;
name: string;
node_id: string;
username: string;
}
type CloudWorkspaceMilestone = Record<string, unknown>;
type CloudWorkspaceLabel = Record<string, unknown>;
type CloudWorkspaceIssueType = Record<string, unknown>;
export interface CloudWorkspacePullRequestData {
id: string;
node_id: string;
number: string;
title: string;
description: string;
url: string;
milestone_id: string;
labels: CloudWorkspaceLabel[];
author_id: string;
author_username: string;
created_date: Date;
updated_date: Date;
closed_date: Date;
merged_date: Date;
first_commit_date: Date;
first_response_date: Date;
comment_count: number;
repository: CloudWorkspaceRepositoryData;
head_commit: {
id: string;
url: string;
build_status: {
context: string;
state: string;
description: string;
};
};
lifecycle_stages: {
stage: string;
start_date: Date;
end_date: Date;
}[];
reviews: CloudWorkspacePullRequestReviews[];
head: {
name: string;
};
}
interface CloudWorkspacePullRequestReviews {
user_id: string;
avatar_url: string;
state: string;
}
export interface CloudWorkspaceIssue {
id: string;
node_id: string;
title: string;
author_id: string;
assignee_ids: string[];
milestone_id: string;
label_ids: string[];
issue_type: string;
url: string;
created_date: Date;
updated_date: Date;
comment_count: number;
repository: CloudWorkspaceRepositoryData;
}
interface CloudWorkspaceConnection<i> {
total_count: number;
page_info: {
start_cursor: string;
end_cursor: string;
has_next_page: boolean;
};
nodes: i[];
}
interface CloudWorkspaceFetchedConnection<i> extends CloudWorkspaceConnection<i> {
is_fetching: boolean;
}
export interface WorkspacesResponse {
data: {
projects: CloudWorkspaceConnection<CloudWorkspaceData>;
};
}
export interface WorkspaceRepositoriesResponse {
data: {
project: {
provider_data: {
repositories: CloudWorkspaceConnection<CloudWorkspaceRepositoryData>;
};
};
};
}
export interface WorkspacePullRequestsResponse {
data: {
project: {
provider_data: {
pull_requests: CloudWorkspaceFetchedConnection<CloudWorkspacePullRequestData>;
};
};
};
}
export interface WorkspacesWithPullRequestsResponse {
data: {
projects: {
nodes: {
provider_data: {
pull_requests: CloudWorkspaceFetchedConnection<CloudWorkspacePullRequestData>;
};
}[];
};
};
errors?: {
message: string;
path: unknown[];
statusCode: number;
}[];
}
export interface WorkspaceIssuesResponse {
data: {
project: {
provider_data: {
issues: CloudWorkspaceFetchedConnection<CloudWorkspaceIssue>;
};
};
};
}
export interface CreateWorkspaceResponse {
data: {
create_project: CloudWorkspaceData | null;
};
}
export interface DeleteWorkspaceResponse {
data: {
delete_project: CloudWorkspaceData | null;
};
}
export type AddRepositoriesToWorkspaceResponse = {
data: {
add_repositories_to_project: {
id: string;
provider_data: {
[repoKey: string]: CloudWorkspaceRepositoryData;
};
} | null;
};
};
export interface RemoveRepositoriesFromWorkspaceResponse {
data: {
remove_repositories_from_project: {
id: string;
} | null;
};
}
export interface AddWorkspaceRepoDescriptor {
owner: string;
repoName: string;
}
// TODO@ramint Switch to using repo id once that is no longer bugged
export interface RemoveWorkspaceRepoDescriptor {
owner: string;
repoName: string;
}
// Local Workspace Types
export class GKLocalWorkspace {
private readonly _type: WorkspaceType = WorkspaceType.Local;
private readonly _id: string;
private readonly _name: string;
private readonly _repositories: LocalWorkspaceRepositoryDescriptor[] | undefined;
constructor(id: string, name: string, repositories?: LocalWorkspaceRepositoryDescriptor[]) {
this._id = id;
this._name = name;
this._repositories = repositories;
}
get type(): WorkspaceType {
return this._type;
}
get id(): string {
return this._id;
}
get name(): string {
return this._name;
}
get repositories(): LocalWorkspaceRepositoryDescriptor[] | undefined {
return this._repositories;
}
isShared(): boolean {
return false;
}
getRepository(name: string): LocalWorkspaceRepositoryDescriptor | undefined {
return this._repositories?.find(r => r.name === name);
}
}
export interface LocalWorkspaceFileData {
workspaces: LocalWorkspaceData;
}
export type LocalWorkspaceData = {
[localWorkspaceId: string]: LocalWorkspaceDescriptor;
};
export interface LocalWorkspaceDescriptor {
localId: string;
profileId: string;
name: string;
description: string;
repositories: LocalWorkspaceRepositoryPath[];
version: number;
}
export interface LocalWorkspaceRepositoryPath {
localPath: string;
}
export interface LocalWorkspaceRepositoryDescriptor extends LocalWorkspaceRepositoryPath {
id?: undefined;
name: string;
}
export interface CloudWorkspaceFileData {
workspaces: CloudWorkspacesPathMap;
}
export type CloudWorkspacesPathMap = {
[cloudWorkspaceId: string]: CloudWorkspaceRepoPaths;
};
export interface CloudWorkspaceRepoPaths {
repoPaths: CloudWorkspaceRepoPathMap;
}
export type CloudWorkspaceRepoPathMap = {
[repoId: string]: string;
};

+ 443
- 0
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<WorkspacesResponse | undefined> {
const accessToken = await this.getAccessToken();
if (accessToken == null) {
return;
}
let queryParams = `(first: ${options?.count ?? defaultWorkspaceCount}`;
if (options?.cursor) {
queryParams += `, after: "${options.cursor}"`;
} else if (options?.page) {
queryParams += `, page: ${options.page}`;
}
queryParams += ')';
let repoQueryParams = `(first: ${options?.repoCount ?? defaultWorkspaceRepoCount}`;
if (options?.repoPage) {
repoQueryParams += `, page: ${options.repoPage}`;
}
repoQueryParams += ')';
const rsp = await this.server.fetchGraphql(
{
query: `
query getWorkspacesWithRepos {
projects ${queryParams} {
total_count
page_info {
end_cursor
has_next_page
}
nodes {
id
description
name
organization {
id
}
provider
provider_data {
repositories ${repoQueryParams} {
total_count
page_info {
end_cursor
has_next_page
}
nodes {
id
name
repository_id
provider
provider_organization_id
provider_organization_name
url
}
}
}
}
}
}
`,
},
accessToken,
);
if (!rsp.ok) {
Logger.error(undefined, `Getting workspaces with repos failed: (${rsp.status}) ${rsp.statusText}`);
throw new Error(rsp.statusText);
}
const json: WorkspacesResponse | undefined = (await rsp.json()) as WorkspacesResponse | undefined;
return json;
}
async getWorkspaces(options?: {
count?: number;
cursor?: string;
page?: number;
}): Promise<WorkspacesResponse | undefined> {
const accessToken = await this.getAccessToken();
if (accessToken == null) {
return;
}
let queryparams = `(first: ${options?.count ?? defaultWorkspaceCount}`;
if (options?.cursor) {
queryparams += `, after: "${options.cursor}"`;
} else if (options?.page) {
queryparams += `, page: ${options.page}`;
}
queryparams += ')';
const rsp = await this.server.fetchGraphql(
{
query: `
query getWorkspaces {
projects ${queryparams} {
total_count
page_info {
end_cursor
has_next_page
}
nodes {
id
description
name
organization {
id
}
provider
}
}
}
`,
},
accessToken,
);
if (!rsp.ok) {
Logger.error(undefined, `Getting workspaces failed: (${rsp.status}) ${rsp.statusText}`);
throw new Error(rsp.statusText);
}
const json: WorkspacesResponse | undefined = (await rsp.json()) as WorkspacesResponse | undefined;
return json;
}
async getWorkspaceRepositories(
workspaceId: string,
options?: {
count?: number;
cursor?: string;
page?: number;
},
): Promise<WorkspaceRepositoriesResponse | undefined> {
const accessToken = await this.getAccessToken();
if (accessToken == null) {
return;
}
let queryparams = `(first: ${options?.count ?? defaultWorkspaceRepoCount}`;
if (options?.cursor) {
queryparams += `, after: "${options.cursor}"`;
} else if (options?.page) {
queryparams += `, page: ${options.page}`;
}
queryparams += ')';
const rsp = await this.server.fetchGraphql(
{
query: `
query getWorkspaceRepos {
project (id: "${workspaceId}") {
provider_data {
repositories ${queryparams} {
total_count
page_info {
end_cursor
has_next_page
}
nodes {
id
name
repository_id
provider
provider_organization_id
provider_organization_name
url
}
}
}
}
}
`,
},
accessToken,
);
if (!rsp.ok) {
Logger.error(undefined, `Getting workspace repos failed: (${rsp.status}) ${rsp.statusText}`);
throw new Error(rsp.statusText);
}
const json: WorkspaceRepositoriesResponse | undefined = (await rsp.json()) as
| WorkspaceRepositoriesResponse
| undefined;
return json;
}
async createWorkspace(options: {
name: string;
description: string;
provider: CloudWorkspaceProviderInputType;
hostUrl?: string;
azureOrganizationName?: string;
azureProjectName?: string;
}): Promise<CreateWorkspaceResponse | undefined> {
if (!options.name || !options.description || !options.provider) {
return;
}
if (
options.provider === CloudWorkspaceProviderInputType.Azure &&
(!options.azureOrganizationName || !options.azureProjectName)
) {
return;
}
if (
(options.provider === CloudWorkspaceProviderInputType.GitHubEnterprise ||
options.provider === CloudWorkspaceProviderInputType.GitLabSelfHosted) &&
!options.hostUrl
) {
return;
}
const accessToken = await this.getAccessToken();
if (accessToken == null) {
return;
}
const rsp = await this.server.fetchGraphql(
{
query: `
mutation createWorkspace {
create_project(
input: {
type: GK_PROJECT
name: "${options.name}"
description: "${options.description}"
provider: ${options.provider}
${options.hostUrl ? `host_url: "${options.hostUrl}"` : ''}
${options.azureOrganizationName ? `azure_organization_id: "${options.azureOrganizationName}"` : ''}
${options.azureProjectName ? `azure_project: "${options.azureProjectName}"` : ''}
profile_id: "shared-services"
}
) {
id,
name,
description,
organization {
id
}
provider
}
}
`,
},
accessToken,
);
if (!rsp.ok) {
Logger.error(undefined, `Creating workspace failed: (${rsp.status}) ${rsp.statusText}`);
throw new Error(rsp.statusText);
}
const json: CreateWorkspaceResponse | undefined = (await rsp.json()) as CreateWorkspaceResponse | undefined;
return json;
}
async deleteWorkspace(workspaceId: string): Promise<DeleteWorkspaceResponse | undefined> {
const accessToken = await this.getAccessToken();
if (accessToken == null) {
return;
}
const rsp = await this.server.fetchGraphql(
{
query: `
mutation deleteWorkspace {
delete_project(
id: "${workspaceId}"
) {
id
}
}
`,
},
accessToken,
);
if (!rsp.ok) {
Logger.error(undefined, `Deleting workspace failed: (${rsp.status}) ${rsp.statusText}`);
throw new Error(rsp.statusText);
}
const json: DeleteWorkspaceResponse | undefined = (await rsp.json()) as DeleteWorkspaceResponse | undefined;
return json;
}
async addReposToWorkspace(
workspaceId: string,
repos: AddWorkspaceRepoDescriptor[],
): Promise<AddRepositoriesToWorkspaceResponse | undefined> {
if (repos.length === 0) {
return;
}
const accessToken = await this.getAccessToken();
if (accessToken == null) {
return;
}
let reposQuery = '[';
reposQuery += repos.map(r => `{ provider_organization_id: "${r.owner}", name: "${r.repoName}" }`).join(',');
reposQuery += ']';
let count = 1;
const reposReturnQuery = repos
.map(
r => `Repository${count++}: repository(provider_organization_id: "${r.owner}", name: "${r.repoName}") {
id
name
repository_id
provider
provider_organization_id
provider_organization_name
url
}`,
)
.join(',');
const rsp = await this.server.fetchGraphql(
{
query: `
mutation addReposToWorkspace {
add_repositories_to_project(
input: {
project_id: "${workspaceId}",
repositories: ${reposQuery}
}
) {
id
provider_data {
${reposReturnQuery}
}
}
}
`,
},
accessToken,
);
if (!rsp.ok) {
Logger.error(undefined, `Adding repositories to workspace failed: (${rsp.status}) ${rsp.statusText}`);
throw new Error(rsp.statusText);
}
const json: AddRepositoriesToWorkspaceResponse | undefined = (await rsp.json()) as
| AddRepositoriesToWorkspaceResponse
| undefined;
return json;
}
async removeReposFromWorkspace(
workspaceId: string,
repos: RemoveWorkspaceRepoDescriptor[],
): Promise<RemoveRepositoriesFromWorkspaceResponse | undefined> {
if (repos.length === 0) {
return;
}
const accessToken = await this.getAccessToken();
if (accessToken == null) {
return;
}
let reposQuery = '[';
reposQuery += repos.map(r => `{ provider_organization_id: "${r.owner}", name: "${r.repoName}" }`).join(',');
reposQuery += ']';
const rsp = await this.server.fetchGraphql(
{
query: `
mutation removeReposFromWorkspace {
remove_repositories_from_project(
input: {
project_id: "${workspaceId}",
repositories: ${reposQuery}
}
) {
id
}
}
`,
},
accessToken,
);
if (!rsp.ok) {
Logger.error(undefined, `Removing repositories from workspace failed: (${rsp.status}) ${rsp.statusText}`);
throw new Error(rsp.statusText);
}
const json: RemoveRepositoriesFromWorkspaceResponse | undefined = (await rsp.json()) as
| RemoveRepositoriesFromWorkspaceResponse
| undefined;
return json;
}
}

+ 16
- 0
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<string | undefined>;
writeCloudWorkspaceDiskPathToMap(cloudWorkspaceId: string, repoId: string, repoLocalPath: string): Promise<void>;
getLocalWorkspaceData(): Promise<LocalWorkspaceFileData>;
writeCodeWorkspaceFile(
uri: Uri,
workspaceRepoFilePaths: string[],
options?: { workspaceId?: string },
): Promise<boolean>;
}

+ 902
- 0
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<void> = new EventEmitter<void>();
get onDidChangeWorkspaces(): Event<void> {
return this._onDidChangeWorkspaces.event;
}
private _disposable: Disposable;
// TODO@ramint Add error handling/logging when this is used.
private readonly _getCloudWorkspaceRepos: (workspaceId: string) => Promise<GetCloudWorkspaceRepositoriesResponse> =
async (workspaceId: string) => {
try {
const workspaceRepos = await this._workspacesApi.getWorkspaceRepositories(workspaceId);
return {
repositories: workspaceRepos?.data?.project?.provider_data?.repositories?.nodes ?? [],
repositoriesInfo: undefined,
};
} catch {
return {
repositories: undefined,
repositoriesInfo: 'Failed to load repositories for this workspace.',
};
}
};
constructor(private readonly container: Container, private readonly server: ServerConnection) {
this._workspacesApi = new WorkspacesApi(this.container, this.server);
this._workspacesPathProvider = getSupportedWorkspacesPathMappingProvider();
this._disposable = Disposable.from(container.subscription.onDidChange(this.onSubscriptionChanged, this));
}
dispose(): void {
this._disposable.dispose();
}
private onSubscriptionChanged(event: SubscriptionChangeEvent): void {
if (
event.current.account == null ||
event.current.account.id !== event.previous?.account?.id ||
event.current.state !== event.previous?.state
) {
this.resetWorkspaces({ cloud: true });
this._onDidChangeWorkspaces.fire();
}
}
private async loadCloudWorkspaces(excludeRepositories: boolean = false): Promise<LoadCloudWorkspacesResponse> {
const subscription = await this.container.subscription.getSubscription();
if (subscription?.account == null) {
return {
cloudWorkspaces: undefined,
cloudWorkspaceInfo: 'Please sign in to use cloud workspaces.',
};
}
const cloudWorkspaces: GKCloudWorkspace[] = [];
let workspaces: CloudWorkspaceData[] | undefined;
try {
const workspaceResponse: WorkspacesResponse | undefined = excludeRepositories
? await this._workspacesApi.getWorkspaces()
: await this._workspacesApi.getWorkspacesWithRepos();
workspaces = workspaceResponse?.data?.projects?.nodes;
} catch {
return {
cloudWorkspaces: undefined,
cloudWorkspaceInfo: 'Failed to load cloud workspaces.',
};
}
let filteredSharedWorkspaceCount = 0;
const isPlusEnabled =
subscription.state === SubscriptionState.FreeInPreviewTrial ||
subscription.state === SubscriptionState.FreePlusInTrial ||
subscription.state === SubscriptionState.Paid;
if (workspaces?.length) {
for (const workspace of workspaces) {
if (!isPlusEnabled && workspace.organization?.id) {
filteredSharedWorkspaceCount += 1;
continue;
}
let repositories: CloudWorkspaceRepositoryDescriptor[] | undefined =
workspace.provider_data?.repositories?.nodes;
if (repositories == null && !excludeRepositories) {
repositories = [];
}
cloudWorkspaces.push(
new GKCloudWorkspace(
workspace.id,
workspace.name,
workspace.organization?.id,
workspace.provider as CloudWorkspaceProviderType,
this._getCloudWorkspaceRepos,
repositories,
),
);
}
}
return {
cloudWorkspaces: cloudWorkspaces,
cloudWorkspaceInfo:
filteredSharedWorkspaceCount > 0
? `${filteredSharedWorkspaceCount} shared workspaces hidden - upgrade to GitLens Pro to access.`
: undefined,
};
}
// TODO@ramint: When we interact more with local workspaces, this should return more info about failures.
private async loadLocalWorkspaces(): Promise<LoadLocalWorkspacesResponse> {
const localWorkspaces: GKLocalWorkspace[] = [];
const workspaceFileData: LocalWorkspaceData =
(await this._workspacesPathProvider.getLocalWorkspaceData())?.workspaces || {};
for (const workspace of Object.values(workspaceFileData)) {
localWorkspaces.push(
new GKLocalWorkspace(
workspace.localId,
workspace.name,
workspace.repositories.map(repositoryPath => ({
localPath: repositoryPath.localPath,
name: repositoryPath.localPath.split(/[\\/]/).pop() ?? 'unknown',
})),
),
);
}
return {
localWorkspaces: localWorkspaces,
localWorkspaceInfo: undefined,
};
}
private getCloudWorkspace(workspaceId: string): GKCloudWorkspace | undefined {
return this._cloudWorkspaces?.find(workspace => workspace.id === workspaceId);
}
private getLocalWorkspace(workspaceId: string): GKLocalWorkspace | undefined {
return this._localWorkspaces?.find(workspace => workspace.id === workspaceId);
}
async getWorkspaces(options?: { excludeRepositories?: boolean; force?: boolean }): Promise<GetWorkspacesResponse> {
const getWorkspacesResponse: GetWorkspacesResponse = {
cloudWorkspaces: [],
localWorkspaces: [],
cloudWorkspaceInfo: undefined,
localWorkspaceInfo: undefined,
};
if (this._cloudWorkspaces == null || options?.force) {
const loadCloudWorkspacesResponse = await this.loadCloudWorkspaces(options?.excludeRepositories);
this._cloudWorkspaces = loadCloudWorkspacesResponse.cloudWorkspaces;
getWorkspacesResponse.cloudWorkspaceInfo = loadCloudWorkspacesResponse.cloudWorkspaceInfo;
}
if (this._localWorkspaces == null || options?.force) {
const loadLocalWorkspacesResponse = await this.loadLocalWorkspaces();
this._localWorkspaces = loadLocalWorkspacesResponse.localWorkspaces;
getWorkspacesResponse.localWorkspaceInfo = loadLocalWorkspacesResponse.localWorkspaceInfo;
}
getWorkspacesResponse.cloudWorkspaces = this._cloudWorkspaces ?? [];
getWorkspacesResponse.localWorkspaces = this._localWorkspaces ?? [];
return getWorkspacesResponse;
}
resetWorkspaces(options?: { cloud?: boolean; local?: boolean }) {
if (options?.cloud ?? true) {
this._cloudWorkspaces = undefined;
}
if (options?.local ?? true) {
this._localWorkspaces = undefined;
}
}
async getCloudWorkspaceRepoPath(cloudWorkspaceId: string, repoId: string): Promise<string | undefined> {
return this._workspacesPathProvider.getCloudWorkspaceRepoPath(cloudWorkspaceId, repoId);
}
async updateCloudWorkspaceRepoLocalPath(workspaceId: string, repoId: string, localPath: string): Promise<void> {
await this._workspacesPathProvider.writeCloudWorkspaceDiskPathToMap(workspaceId, repoId, localPath);
}
async locateAllCloudWorkspaceRepos(workspaceId: string, cancellation?: CancellationToken): Promise<void> {
const workspace = this.getCloudWorkspace(workspaceId);
if (workspace == null) return;
const repoDescriptors = workspace.repositories;
if (repoDescriptors == null || repoDescriptors.length === 0) return;
const parentUri = (
await window.showOpenDialog({
title: `Choose a folder containing the repositories in this workspace`,
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: false,
})
)?.[0];
if (parentUri == null || cancellation?.isCancellationRequested) return;
let foundRepos;
try {
foundRepos = await this.container.git.findRepositories(parentUri, {
cancellation: cancellation,
depth: 1,
silent: true,
});
} catch (ex) {
foundRepos = [];
return;
}
if (foundRepos.length === 0 || cancellation?.isCancellationRequested) return;
// Map repos by provider/owner/name
const foundReposMap = new Map<string, Repository>();
const foundReposNameMap = new Map<string, Repository>();
for (const repo of foundRepos) {
foundReposNameMap.set(repo.name.toLowerCase(), repo);
if (cancellation?.isCancellationRequested) break;
const remotes = await repo.getRemotes();
for (const remote of remotes) {
if (remote.provider?.owner == null) continue;
foundReposMap.set(
`${remote.provider.id.toLowerCase()}/${remote.provider.owner.toLowerCase()}/${remote.provider.path
.split('/')
.pop()
?.toLowerCase()}`,
repo,
);
}
}
for (const repoDescriptor of repoDescriptors) {
const foundRepo =
foundReposMap.get(
`${repoDescriptor.provider.toLowerCase()}/${repoDescriptor.provider_organization_id.toLowerCase()}/${repoDescriptor.name.toLowerCase()}`,
) ?? foundReposNameMap.get(repoDescriptor.name.toLowerCase());
if (foundRepo != null) {
await this.locateWorkspaceRepo(workspaceId, repoDescriptor, foundRepo);
if (cancellation?.isCancellationRequested) return;
}
}
}
async locateWorkspaceRepo(
workspaceId: string,
descriptor: CloudWorkspaceRepositoryDescriptor | LocalWorkspaceRepositoryDescriptor,
): Promise<void>;
async locateWorkspaceRepo(
workspaceId: string,
descriptor: CloudWorkspaceRepositoryDescriptor | LocalWorkspaceRepositoryDescriptor,
// eslint-disable-next-line @typescript-eslint/unified-signatures
uri: Uri,
): Promise<void>;
async locateWorkspaceRepo(
workspaceId: string,
descriptor: CloudWorkspaceRepositoryDescriptor | LocalWorkspaceRepositoryDescriptor,
// eslint-disable-next-line @typescript-eslint/unified-signatures
repository: Repository,
): Promise<void>;
async locateWorkspaceRepo(
workspaceId: string,
descriptor: CloudWorkspaceRepositoryDescriptor | LocalWorkspaceRepositoryDescriptor,
uriOrRepository?: Uri | Repository,
): Promise<void> {
let repo;
if (uriOrRepository == null || uriOrRepository instanceof Uri) {
let repoLocatedUri = uriOrRepository;
if (repoLocatedUri == null) {
repoLocatedUri = (
await window.showOpenDialog({
title: `Choose a location for ${descriptor.name}`,
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: false,
})
)?.[0];
}
if (repoLocatedUri == null) return;
repo = await this.container.git.getOrOpenRepository(repoLocatedUri, {
closeOnOpen: true,
detectNested: false,
});
if (repo == null) return;
} else {
repo = uriOrRepository;
}
const repoPath = repo.uri.fsPath;
const remotes = await repo.getRemotes();
const remoteUrls: string[] = [];
for (const remote of remotes) {
const remoteUrl = remote.provider?.url({ type: RemoteResourceType.Repo });
if (remoteUrl != null) {
remoteUrls.push(remoteUrl);
}
}
for (const remoteUrl of remoteUrls) {
await this.container.repositoryPathMapping.writeLocalRepoPath({ remoteUrl: remoteUrl }, repoPath);
}
if (descriptor.id != null) {
await this.container.repositoryPathMapping.writeLocalRepoPath(
{
remoteUrl: descriptor.url,
repoInfo: {
provider: descriptor.provider,
owner: descriptor.provider_organization_id,
repoName: descriptor.name,
},
},
repoPath,
);
await this.updateCloudWorkspaceRepoLocalPath(workspaceId, descriptor.id, repoPath);
}
}
async createCloudWorkspace(options?: { repos?: Repository[] }): Promise<void> {
const input = window.createInputBox();
input.title = 'Create Cloud Workspace';
const quickpick = window.createQuickPick();
quickpick.title = 'Create Cloud Workspace';
const quickpickLabelToProviderType: { [label: string]: CloudWorkspaceProviderInputType } = {
GitHub: CloudWorkspaceProviderInputType.GitHub,
'GitHub Enterprise': CloudWorkspaceProviderInputType.GitHubEnterprise,
// TODO add support for these in the future
// GitLab: CloudWorkspaceProviderInputType.GitLab,
// 'GitLab Self-Managed': CloudWorkspaceProviderInputType.GitLabSelfHosted,
// Bitbucket: CloudWorkspaceProviderInputType.Bitbucket,
// Azure: CloudWorkspaceProviderInputType.Azure,
};
input.ignoreFocusOut = true;
const disposables: Disposable[] = [];
let workspaceName: string | undefined;
let workspaceDescription = '';
let hostUrl: string | undefined;
let azureOrganizationName: string | undefined;
let azureProjectName: string | undefined;
let workspaceProvider: CloudWorkspaceProviderInputType | undefined;
let matchingProviderRepos: Repository[] = [];
if (options?.repos != null && options.repos.length > 0) {
// Currently only GitHub is supported.
for (const repo of options.repos) {
const repoRemotes = await repo.getRemotes({ filter: r => r.domain === 'github.com' });
if (repoRemotes.length === 0) {
await window.showErrorMessage(
`Only GitHub is supported for this operation. Please ensure all open repositories are hosted on GitHub.`,
{ modal: true },
);
return;
}
}
workspaceProvider = CloudWorkspaceProviderInputType.GitHub;
matchingProviderRepos = options.repos;
}
let includeReposResponse;
try {
workspaceName = await new Promise<string | undefined>(resolve => {
disposables.push(
input.onDidHide(() => resolve(undefined)),
input.onDidAccept(() => {
const value = input.value.trim();
if (!value) {
input.validationMessage = 'Please enter a non-empty name for the workspace';
return;
}
resolve(value);
}),
);
input.placeholder = 'Please enter a name for the new workspace';
input.prompt = 'Enter your workspace name';
input.show();
});
if (!workspaceName) return;
workspaceDescription = await new Promise<string>(resolve => {
disposables.push(
input.onDidHide(() => resolve('')),
input.onDidAccept(() => {
const value = input.value.trim();
resolve(value || '');
}),
);
input.value = '';
input.title = 'Create Workspace';
input.placeholder = 'Please enter a description for the new workspace';
input.prompt = 'Enter your workspace description';
input.show();
});
if (workspaceProvider == null) {
workspaceProvider = await new Promise<CloudWorkspaceProviderInputType | undefined>(resolve => {
disposables.push(
quickpick.onDidHide(() => resolve(undefined)),
quickpick.onDidAccept(() => {
if (quickpick.activeItems.length !== 0) {
resolve(quickpickLabelToProviderType[quickpick.activeItems[0].label]);
}
}),
);
quickpick.placeholder = 'Please select a provider for the new workspace';
quickpick.items = Object.keys(quickpickLabelToProviderType).map(label => ({ label: label }));
quickpick.canSelectMany = false;
quickpick.show();
});
}
if (!workspaceProvider) return;
if (
workspaceProvider == CloudWorkspaceProviderInputType.GitHubEnterprise ||
workspaceProvider == CloudWorkspaceProviderInputType.GitLabSelfHosted
) {
hostUrl = await new Promise<string | undefined>(resolve => {
disposables.push(
input.onDidHide(() => resolve(undefined)),
input.onDidAccept(() => {
const value = input.value.trim();
if (!value) {
input.validationMessage = 'Please enter a non-empty host URL for the workspace';
return;
}
resolve(value);
}),
);
input.value = '';
input.placeholder = 'Please enter a host URL for the new workspace';
input.prompt = 'Enter your workspace host URL';
input.show();
});
if (!hostUrl) return;
}
if (workspaceProvider == CloudWorkspaceProviderInputType.Azure) {
azureOrganizationName = await new Promise<string | undefined>(resolve => {
disposables.push(
input.onDidHide(() => resolve(undefined)),
input.onDidAccept(() => {
const value = input.value.trim();
if (!value) {
input.validationMessage =
'Please enter a non-empty organization name for the workspace';
return;
}
resolve(value);
}),
);
input.value = '';
input.placeholder = 'Please enter an organization name for the new workspace';
input.prompt = 'Enter your workspace organization name';
input.show();
});
if (!azureOrganizationName) return;
azureProjectName = await new Promise<string | undefined>(resolve => {
disposables.push(
input.onDidHide(() => resolve(undefined)),
input.onDidAccept(() => {
const value = input.value.trim();
if (!value) {
input.validationMessage = 'Please enter a non-empty project name for the workspace';
return;
}
resolve(value);
}),
);
input.value = '';
input.placeholder = 'Please enter a project name for the new workspace';
input.prompt = 'Enter your workspace project name';
input.show();
});
if (!azureProjectName) return;
}
if (workspaceProvider != null && matchingProviderRepos.length === 0) {
for (const repo of this.container.git.openRepositories) {
const matchingRemotes = await repo.getRemotes({
filter: r =>
r.provider?.id === cloudWorkspaceProviderInputTypeToRemoteProviderId[workspaceProvider!],
});
if (matchingRemotes.length) {
matchingProviderRepos.push(repo);
}
}
if (matchingProviderRepos.length) {
includeReposResponse = await window.showInformationMessage(
'Would you like to include your open repositories in the workspace?',
{ modal: true },
{ title: 'Yes' },
{ title: 'No', isCloseAffordance: true },
);
}
}
} finally {
input.dispose();
quickpick.dispose();
disposables.forEach(d => void d.dispose());
}
const createOptions = {
name: workspaceName,
description: workspaceDescription,
provider: workspaceProvider,
hostUrl: hostUrl,
azureOrganizationName: azureOrganizationName,
azureProjectName: azureProjectName,
};
let createdProjectData: CloudWorkspaceData | null | undefined;
try {
const response = await this._workspacesApi.createWorkspace(createOptions);
createdProjectData = response?.data?.create_project;
} catch {
return;
}
if (createdProjectData != null) {
// Add the new workspace to cloud workspaces
if (this._cloudWorkspaces == null) {
this._cloudWorkspaces = [];
}
this._cloudWorkspaces?.push(
new GKCloudWorkspace(
createdProjectData.id,
createdProjectData.name,
createdProjectData.organization?.id,
createdProjectData.provider as CloudWorkspaceProviderType,
this._getCloudWorkspaceRepos,
[],
),
);
const newWorkspace = this.getCloudWorkspace(createdProjectData.id);
if (newWorkspace != null && (includeReposResponse?.title === 'Yes' || options?.repos)) {
const repoInputs: { repo: Repository; inputDescriptor: AddWorkspaceRepoDescriptor }[] = [];
for (const repo of matchingProviderRepos) {
const remote = (await repo.getRemote('origin')) || (await repo.getRemotes())?.[0];
const remoteOwnerAndName = remote?.provider?.path?.split('/') || remote?.path?.split('/');
if (remoteOwnerAndName == null || remoteOwnerAndName.length !== 2) continue;
repoInputs.push({
repo: repo,
inputDescriptor: { owner: remoteOwnerAndName[0], repoName: remoteOwnerAndName[1] },
});
}
if (repoInputs.length) {
let newRepoDescriptors: CloudWorkspaceRepositoryDescriptor[] = [];
try {
const response = await this._workspacesApi.addReposToWorkspace(
newWorkspace.id,
repoInputs.map(r => r.inputDescriptor),
);
if (response?.data.add_repositories_to_project == null) return;
newRepoDescriptors = Object.values(
response.data.add_repositories_to_project.provider_data,
) as CloudWorkspaceRepositoryDescriptor[];
} catch {
return;
}
if (newRepoDescriptors.length === 0) return;
newWorkspace.addRepositories(newRepoDescriptors);
for (const repoInput of repoInputs) {
const repoDescriptor = newRepoDescriptors.find(
r => r.name === repoInput.inputDescriptor.repoName,
);
if (!repoDescriptor) continue;
await this.locateWorkspaceRepo(newWorkspace.id, repoDescriptor, repoInput.repo);
}
}
}
}
}
async deleteCloudWorkspace(workspaceId: string) {
const confirmation = await window.showWarningMessage(
`Are you sure you want to delete this workspace? This cannot be undone.`,
{ modal: true },
{ title: 'Confirm' },
{ title: 'Cancel', isCloseAffordance: true },
);
if (confirmation == null || confirmation.title == 'Cancel') return;
try {
const response = await this._workspacesApi.deleteWorkspace(workspaceId);
if (response?.data?.delete_project?.id === workspaceId) {
// Remove the workspace from the local workspace list.
this._cloudWorkspaces = this._cloudWorkspaces?.filter(w => w.id !== workspaceId);
}
} catch {}
}
async addCloudWorkspaceRepo(workspaceId: string) {
const workspace = this.getCloudWorkspace(workspaceId);
if (workspace == null) return;
const matchingProviderRepos = [];
for (const repo of this.container.git.openRepositories) {
const matchingRemotes = await repo.getRemotes({
filter: r => r.provider?.id === cloudWorkspaceProviderTypeToRemoteProviderId[workspace.provider],
});
if (matchingRemotes.length) {
matchingProviderRepos.push(repo);
}
}
if (!matchingProviderRepos.length) {
void window.showInformationMessage(`No open repositories found for provider ${workspace.provider}`);
return;
}
const pick = await showRepositoryPicker(
'Add Repository to Workspace',
'Choose which repository to add to the workspace',
matchingProviderRepos,
);
if (pick?.item == null) return;
const repoPath = pick.repoPath;
const repo = this.container.git.getRepository(repoPath);
if (repo == null) return;
const remote = (await repo.getRemote('origin')) || (await repo.getRemotes())?.[0];
const remoteOwnerAndName = remote?.provider?.path?.split('/') || remote?.path?.split('/');
if (remoteOwnerAndName == null || remoteOwnerAndName.length !== 2) return;
let newRepoDescriptors: CloudWorkspaceRepositoryDescriptor[] = [];
try {
const response = await this._workspacesApi.addReposToWorkspace(workspaceId, [
{ owner: remoteOwnerAndName[0], repoName: remoteOwnerAndName[1] },
]);
if (response?.data.add_repositories_to_project == null) return;
newRepoDescriptors = Object.values(
response.data.add_repositories_to_project.provider_data,
) as CloudWorkspaceRepositoryDescriptor[];
} catch {
return;
}
if (newRepoDescriptors.length === 0) return;
workspace.addRepositories(newRepoDescriptors);
await this.locateWorkspaceRepo(workspaceId, newRepoDescriptors[0], repo);
}
async removeCloudWorkspaceRepo(workspaceId: string, descriptor: CloudWorkspaceRepositoryDescriptor) {
const workspace = this.getCloudWorkspace(workspaceId);
if (workspace == null) return;
const confirmation = await window.showWarningMessage(
`Are you sure you want to remove ${descriptor.name} from this workspace? This cannot be undone.`,
{ modal: true },
{ title: 'Confirm' },
{ title: 'Cancel', isCloseAffordance: true },
);
if (confirmation == null || confirmation.title == 'Cancel') return;
try {
const response = await this._workspacesApi.removeReposFromWorkspace(workspaceId, [
{ owner: descriptor.provider_organization_id, repoName: descriptor.name },
]);
if (response?.data.remove_repositories_from_project == null) return;
workspace.removeRepositories([descriptor.name]);
} catch {}
}
async resolveWorkspaceRepositoriesByName(
workspaceId: string,
workspaceType: WorkspaceType,
): Promise<WorkspaceRepositoriesByName> {
const workspaceRepositoriesByName: WorkspaceRepositoriesByName = new Map<string, Repository>();
const workspace: GKCloudWorkspace | GKLocalWorkspace | undefined =
workspaceType === WorkspaceType.Cloud
? this.getCloudWorkspace(workspaceId)
: this.getLocalWorkspace(workspaceId);
if (workspace?.repositories == null) return workspaceRepositoriesByName;
for (const repository of workspace.repositories) {
const currentRepositories = this.container.git.repositories;
let repo: Repository | undefined = undefined;
let repoId: string | undefined = undefined;
let repoLocalPath: string | undefined = undefined;
let repoRemoteUrl: string | undefined = undefined;
let repoName: string | undefined = undefined;
let repoProvider: string | undefined = undefined;
let repoOwner: string | undefined = undefined;
if (workspaceType === WorkspaceType.Local) {
repoLocalPath = (repository as LocalWorkspaceRepositoryDescriptor).localPath;
// repo name in this case is the last part of the path after splitting from the path separator
repoName = (repository as LocalWorkspaceRepositoryDescriptor).name;
for (const currentRepository of currentRepositories) {
if (currentRepository.path.replaceAll('\\', '/') === repoLocalPath.replaceAll('\\', '/')) {
repo = currentRepository;
}
}
} else if (workspaceType === WorkspaceType.Cloud) {
repoId = (repository as CloudWorkspaceRepositoryDescriptor).id;
repoLocalPath = await this.getCloudWorkspaceRepoPath(workspaceId, repoId);
repoRemoteUrl = (repository as CloudWorkspaceRepositoryDescriptor).url;
repoName = (repository as CloudWorkspaceRepositoryDescriptor).name;
repoProvider = (repository as CloudWorkspaceRepositoryDescriptor).provider;
repoOwner = (repository as CloudWorkspaceRepositoryDescriptor).provider_organization_id;
if (repoLocalPath == null) {
const repoLocalPaths = await this.container.repositoryPathMapping.getLocalRepoPaths({
remoteUrl: repoRemoteUrl,
repoInfo: {
repoName: repoName,
provider: repoProvider,
owner: repoOwner,
},
});
// TODO@ramint: The user should be able to choose which path to use if multiple available
if (repoLocalPaths.length > 0) {
repoLocalPath = repoLocalPaths[0];
}
}
for (const currentRepository of currentRepositories) {
if (
repoLocalPath != null &&
currentRepository.path.replaceAll('\\', '/') === repoLocalPath.replaceAll('\\', '/')
) {
repo = currentRepository;
}
}
}
// TODO: Add this logic back in once we think through virtual repository support a bit more.
// We want to support virtual repositories not just as an automatic backup, but as a user choice.
/*if (!repo) {
let uri: Uri | undefined = undefined;
if (repoLocalPath) {
uri = Uri.file(repoLocalPath);
} else if (repoRemoteUrl) {
uri = Uri.parse(repoRemoteUrl);
uri = uri.with({
scheme: Schemes.Virtual,
authority: encodeAuthority<GitHubAuthorityMetadata>('github'),
path: uri.path,
});
}
if (uri) {
repo = await this.container.git.getOrOpenRepository(uri, { closeOnOpen: true });
}
}*/
if (repoLocalPath != null && !repo) {
repo = await this.container.git.getOrOpenRepository(Uri.file(repoLocalPath), { closeOnOpen: true });
}
if (!repoName || !repo) {
continue;
}
workspaceRepositoriesByName.set(repoName, repo);
}
return workspaceRepositoriesByName;
}
async saveAsCodeWorkspaceFile(
workspaceId: string,
workspaceType: WorkspaceType,
options?: { open?: boolean },
): Promise<void> {
const workspace: GKCloudWorkspace | GKLocalWorkspace | undefined =
workspaceType === WorkspaceType.Cloud
? this.getCloudWorkspace(workspaceId)
: this.getLocalWorkspace(workspaceId);
if (workspace?.repositories == null) return;
const workspaceRepositoriesByName = await this.resolveWorkspaceRepositoriesByName(workspaceId, workspaceType);
if (workspaceRepositoriesByName.size === 0) {
void window.showErrorMessage('No repositories could be found in this workspace.', { modal: true });
return;
}
const workspaceFolderPaths: string[] = [];
for (const repo of workspaceRepositoriesByName.values()) {
if (!repo.virtual && repo.path != null) {
workspaceFolderPaths.push(repo.path);
}
}
if (workspaceFolderPaths.length < workspace.repositories.length) {
const confirmation = await window.showWarningMessage(
`Some repositories in this workspace could not be located locally. Do you want to continue?`,
{ modal: true },
{ title: 'Continue' },
{ title: 'Cancel', isCloseAffordance: true },
);
if (confirmation == null || confirmation.title == 'Cancel') return;
}
// Have the user choose a name and location for the new workspace file
const newWorkspaceUri = await window.showSaveDialog({
defaultUri: Uri.file(`${workspace.name}.code-workspace`),
filters: {
'Code Workspace': ['code-workspace'],
},
title: 'Choose a location for the new code workspace file',
});
if (newWorkspaceUri == null) return;
const created = await this._workspacesPathProvider.writeCodeWorkspaceFile(
newWorkspaceUri,
workspaceFolderPaths,
{ workspaceId: workspaceId },
);
if (!created) {
void window.showErrorMessage('Could not create the new workspace file. Check logs for details');
return;
}
if (options?.open) {
openWorkspace(newWorkspaceUri, { location: OpenWorkspaceLocation.NewWindow });
}
}
}
// TODO: Add back in once we think through virtual repository support a bit more.
/* function encodeAuthority<T>(scheme: string, metadata?: T): string {
return `${scheme}${metadata != null ? `+${encodeUtf8Hex(JSON.stringify(metadata))}` : ''}`;
} */

+ 6
- 3
src/views/nodes/UncommittedFilesNode.ts Переглянути файл

@ -19,8 +19,8 @@ import { ContextValues, ViewNode } from './viewNode';
export class UncommittedFilesNode extends ViewNode<ViewsWithWorkingTree> {
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[] {

+ 24
- 7
src/views/nodes/branchNode.ts Переглянути файл

@ -39,8 +39,8 @@ type State = {
export class BranchNode extends ViewRefNode<ViewsWithBranches, GitBranchReference, State> 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
showStatus: boolean;
showTracking: boolean;
authors?: GitUser[];
workspaceId?: string;
};
protected override splatted = true;
@ -72,6 +73,7 @@ export class BranchNode extends ViewRefNode
showStatus?: boolean;
showTracking?: boolean;
authors?: GitUser[];
workspaceId?: string;
},
) {
super(uri, view, parent);
@ -96,7 +98,7 @@ export class BranchNode extends ViewRefNode
}
override get id(): string {
return BranchNode.getId(this.branch.repoPath, this.branch.name, this.root);
return BranchNode.getId(this.branch.repoPath, this.branch.name, this.root, this.options?.workspaceId);
}
compacted: boolean = false;
@ -226,6 +228,7 @@ export class BranchNode extends ViewRefNode
branch,
this.options.showComparison,
this.splatted,
{ workspaceId: this.options.workspaceId },
),
);
}
@ -247,6 +250,7 @@ export class BranchNode extends ViewRefNode
mergeStatus,
status ?? (await this.view.container.git.getStatusForRepo(this.uri.repoPath)),
this.root,
{ workspaceId: this.options?.workspaceId },
),
);
} else if (
@ -262,6 +266,7 @@ export class BranchNode extends ViewRefNode
rebaseStatus,
status ?? (await this.view.container.git.getStatusForRepo(this.uri.repoPath)),
this.root,
{ workspaceId: this.options?.workspaceId },
),
);
} else if (this.options.showTracking) {
@ -274,22 +279,34 @@ export class BranchNode extends ViewRefNode
if (branch.upstream != null) {
if (this.root && !status.state.behind && !status.state.ahead) {
children.push(new BranchTrackingStatusNode(this.view, this, branch, status, 'same', this.root));
children.push(
new BranchTrackingStatusNode(this.view, this, branch, status, 'same', this.root, {
workspaceId: this.options?.workspaceId,
}),
);
} else {
if (status.state.behind) {
children.push(
new BranchTrackingStatusNode(this.view, this, branch, status, 'behind', this.root),
new BranchTrackingStatusNode(this.view, this, branch, status, 'behind', this.root, {
workspaceId: this.options?.workspaceId,
}),
);
}
if (status.state.ahead) {
children.push(
new BranchTrackingStatusNode(this.view, this, branch, status, 'ahead', this.root),
new BranchTrackingStatusNode(this.view, this, branch, status, 'ahead', this.root, {
workspaceId: this.options?.workspaceId,
}),
);
}
}
} else {
children.push(new BranchTrackingStatusNode(this.view, this, branch, status, 'none', this.root));
children.push(
new BranchTrackingStatusNode(this.view, this, branch, status, 'none', this.root, {
workspaceId: this.options?.workspaceId,
}),
);
}
}

+ 17
- 3
src/views/nodes/branchOrTagFolderNode.ts Переглянути файл

@ -8,8 +8,14 @@ import type { TagNode } from './tagNode';
import { ContextValues, ViewNode } from './viewNode';
export class BranchOrTagFolderNode extends ViewNode {
static getId(repoPath: string, key: string | undefined, type: string, relativePath: string | undefined): string {
return `${RepositoryNode.getId(repoPath)}:${
static getId(
repoPath: string,
key: string | undefined,
type: string,
relativePath: string | undefined,
workspaceId?: string,
): string {
return `${RepositoryNode.getId(repoPath, workspaceId)}:${
key === undefined ? type : `${key}:${type}`
}-folder(${relativePath})`;
}
@ -24,6 +30,7 @@ export class BranchOrTagFolderNode extends ViewNode {
public readonly root: HierarchicalItem<BranchNode | TagNode>,
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;

+ 11
- 2
src/views/nodes/branchTrackingStatusFilesNode.ts Переглянути файл

@ -18,8 +18,15 @@ import { ContextValues, ViewNode } from './viewNode';
export class BranchTrackingStatusFilesNode extends ViewNode<ViewsWithCommits> {
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,
);
}

+ 7
- 1
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<BranchTrackingStatus>,
this.upstreamType,
this.root,
{ workspaceId: this.options?.workspaceId },
).getChildren()),
);
} else {
@ -143,6 +148,7 @@ export class BranchTrackingStatusNode extends ViewNode impleme
this.status as Required<BranchTrackingStatus>,
this.upstreamType,
this.root,
{ workspaceId: this.options?.workspaceId },
),
);
}

+ 7
- 3
src/views/nodes/branchesNode.ts Переглянути файл

@ -15,8 +15,8 @@ import { ContextValues, ViewNode } from './viewNode';
export class BranchesNode extends ViewNode<ViewsWithBranchesNode> {
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();
}

+ 4
- 3
src/views/nodes/compareBranchNode.ts Переглянути файл

@ -22,8 +22,8 @@ import { ContextValues, ViewNode } from './viewNode';
export class CompareBranchNode extends ViewNode<ViewsWithBranches | WorktreesView> {
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
private showComparison: ViewShowBranchComparison,
// Specifies that the node is shown as a root
public readonly root: boolean = false,
private readonly options?: { workspaceId?: string },
) {
super(uri, view, parent);
@ -58,7 +59,7 @@ export class CompareBranchNode extends ViewNode
}
override get id(): string {
return CompareBranchNode.getId(this.branch.repoPath, this.branch.name, this.root);
return CompareBranchNode.getId(this.branch.repoPath, this.branch.name, this.root, this.options?.workspaceId);
}
get repoPath(): string {

+ 4
- 1
src/views/nodes/contributorNode.ts Переглянути файл

@ -25,8 +25,9 @@ export class ContributorNode 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<string, ContactPresence> | 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,
);
}

+ 13
- 4
src/views/nodes/contributorsNode.ts Переглянути файл

@ -14,8 +14,8 @@ import { ContextValues, ViewNode } from './viewNode';
export class ContributorsNode extends ViewNode<ViewsWithContributorsNode> {
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,
}),
);
}

+ 9
- 3
src/views/nodes/mergeStatusNode.ts Переглянути файл

@ -18,8 +18,8 @@ import { ContextValues, ViewNode } from './viewNode';
export class MergeStatusNode extends ViewNode<ViewsWithCommits> {
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 {

+ 9
- 3
src/views/nodes/rebaseStatusNode.ts Переглянути файл

@ -28,8 +28,8 @@ import { ContextValues, ViewNode, ViewRefNode } from './viewNode';
export class RebaseStatusNode extends ViewNode<ViewsWithCommits> {
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 {

+ 14
- 5
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<RepositoriesView> implements PageableViewNode {
export class ReflogNode extends ViewNode<RepositoriesView | WorkspacesView> 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<ViewNode[]> {

+ 7
- 3
src/views/nodes/remoteNode.ts Переглянути файл

@ -16,8 +16,8 @@ import { ContextValues, ViewNode } from './viewNode';
export class RemoteNode extends ViewNode<ViewsWithRemotes> {
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<ViewNode[]> {
@ -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;

+ 16
- 5
src/views/nodes/remotesNode.ts Переглянути файл

@ -11,18 +11,26 @@ import { ContextValues, ViewNode } from './viewNode';
export class RemotesNode extends ViewNode<ViewsWithRemotesNode> {
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;

+ 11
- 2
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
}
getTreeItem(): TreeItem {
const item = new TreeItem('Repositories', TreeItemCollapsibleState.Expanded);
item.contextValue = ContextValues.Repositories;
const isInWorkspacesView = this.view instanceof WorkspacesView;
const item = new TreeItem(
isInWorkspacesView ? 'Current Window' : 'Repositories',
isInWorkspacesView ? TreeItemCollapsibleState.Collapsed : TreeItemCollapsibleState.Expanded,
);
let contextValue: string = ContextValues.Repositories;
if (isInWorkspacesView) {
contextValue += '+workspaces';
}
item.contextValue = contextValue;
return item;
}

+ 112
- 20
src/views/nodes/repositoryNode.ts Переглянути файл

@ -1,4 +1,4 @@
import { Disposable, MarkdownString, TreeItem, TreeItemCollapsibleState } from 'vscode';
import { Disposable, MarkdownString, TreeItem, TreeItemCollapsibleState, Uri } from 'vscode';
import { GlyphChars } from '../../constants';
import { Features } from '../../features';
import type { GitUri } from '../../git/gitUri';
@ -7,6 +7,11 @@ import { GitRemote } from '../../git/models/remote';
import type { RepositoryChangeEvent, RepositoryFileSystemChangeEvent } from '../../git/models/repository';
import { Repository, RepositoryChange, RepositoryChangeComparisonMode } from '../../git/models/repository';
import type { GitStatus } from '../../git/models/status';
import type {
CloudWorkspaceRepositoryDescriptor,
LocalWorkspaceRepositoryDescriptor,
} from '../../plus/workspaces/models';
import { GKCloudWorkspace, GKLocalWorkspace } from '../../plus/workspaces/models';
import { findLastIndex } from '../../system/array';
import { gate } from '../../system/decorators/gate';
import { debug, log } from '../../system/decorators/log';
@ -32,14 +37,23 @@ import { WorktreesNode } from './worktreesNode';
export class RepositoryNode extends SubscribeableViewNode<ViewsWithRepositories> {
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<GitStatus | undefined>;
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<ViewNode[]> {
@ -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);
}

+ 7
- 4
src/views/nodes/stashNode.ts Переглянути файл

@ -17,15 +17,18 @@ import { ContextValues, ViewRefNode } from './viewNode';
export class StashNode extends ViewRefNode<ViewsWithStashes, GitStashReference> {
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 {

+ 18
- 5
src/views/nodes/stashesNode.ts Переглянути файл

@ -12,18 +12,26 @@ import { ContextValues, ViewNode } from './viewNode';
export class StashesNode extends ViewNode<ViewsWithStashesNode> {
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<ViewNode[]> {
@ -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;

+ 6
- 3
src/views/nodes/statusFilesNode.ts Переглянути файл

@ -20,8 +20,8 @@ import { ContextValues, ViewNode } from './viewNode';
export class StatusFilesNode extends ViewNode<ViewsWithWorkingTree> {
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<ViewNode[]> {

+ 12
- 4
src/views/nodes/tagNode.ts Переглянути файл

@ -21,11 +21,19 @@ import { ContextValues, ViewRefNode } from './viewNode';
export class TagNode extends ViewRefNode<ViewsWithTags, GitTagReference> 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 {

+ 10
- 4
src/views/nodes/tagsNode.ts Переглянути файл

@ -14,8 +14,8 @@ import { ContextValues, ViewNode } from './viewNode';
export class TagsNode extends ViewNode<ViewsWithTagsNode> {
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();
}

+ 4
- 0
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',
}

+ 57
- 0
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<WorkspacesView> {
static key = ':workspaceMissingRepository';
static getId(workspaceId: string, repoName: string): string {
return `gitlens${this.key}(${workspaceId}/${repoName})`;
}
constructor(
view: WorkspacesView,
parent: ViewNode,
public readonly workspaceId: string,
public readonly workspaceRepositoryDescriptor:
| CloudWorkspaceRepositoryDescriptor
| LocalWorkspaceRepositoryDescriptor,
) {
super(unknownGitUri, view, parent);
}
override toClipboard(): string {
return this.name;
}
override get id(): string {
return WorkspaceMissingRepositoryNode.getId(this.workspaceId, this.workspaceRepositoryDescriptor.name);
}
get name(): string {
return this.workspaceRepositoryDescriptor.name;
}
getChildren(): ViewNode[] {
return [];
}
getTreeItem(): TreeItem {
const description = 'repo not found \u2022 please locate';
const icon: ThemeIcon = new ThemeIcon('question');
const item = new TreeItem(this.name, TreeItemCollapsibleState.None);
item.id = this.id;
item.description = description;
item.tooltip = `${this.name} (missing)`;
item.contextValue = ContextValues.WorkspaceMissingRepository;
item.iconPath = icon;
item.resourceUri = Uri.parse(`gitlens-view://workspaces/repository/missing`);
return item;
}
}

+ 142
- 0
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<WorkspacesView> {
static key = ':workspace';
static getId(workspaceId: string): string {
return `gitlens${this.key}(${workspaceId})`;
}
private _workspace: GKCloudWorkspace | GKLocalWorkspace;
private _type: WorkspaceType;
constructor(
uri: GitUri,
view: WorkspacesView,
parent: ViewNode,
public readonly workspace: GKCloudWorkspace | GKLocalWorkspace,
) {
super(uri, view, parent);
this._workspace = workspace;
this._type = workspace.type;
}
override get id(): string {
return WorkspaceNode.getId(this._workspace.id ?? '');
}
get name(): string {
return this._workspace?.name ?? '';
}
get workspaceId(): string {
return this._workspace.id ?? '';
}
get type(): WorkspaceType {
return this._type;
}
private async getRepositories(): Promise<
CloudWorkspaceRepositoryDescriptor[] | LocalWorkspaceRepositoryDescriptor[] | undefined
> {
return Promise.resolve(this._workspace?.repositories);
}
private _children: ViewNode[] | undefined;
async getChildren(): Promise<ViewNode[]> {
if (this._children == null) {
this._children = [];
let repositories: CloudWorkspaceRepositoryDescriptor[] | LocalWorkspaceRepositoryDescriptor[] | undefined;
let repositoryInfo: string | undefined;
if (this.workspace instanceof GKLocalWorkspace) {
repositories = (await this.getRepositories()) ?? [];
} else {
const { repositories: repos, repositoriesInfo: repoInfo } =
await this.workspace.getOrLoadRepositories();
repositories = repos;
repositoryInfo = repoInfo;
}
if (repositories?.length === 0) {
this._children.push(new MessageNode(this.view, this, 'No repositories in this workspace.'));
return this._children;
} else if (repositories?.length) {
const reposByName: WorkspaceRepositoriesByName =
await this.view.container.workspaces.resolveWorkspaceRepositoriesByName(
this.workspaceId,
this.type,
);
for (const repository of repositories) {
const repo = reposByName.get(repository.name);
if (!repo) {
this._children.push(
new WorkspaceMissingRepositoryNode(this.view, this, this.workspaceId, repository),
);
continue;
}
this._children.push(
new RepositoryNode(GitUri.fromRepoPath(repo.path), this.view, this, repo, {
workspace: this._workspace,
workspaceRepoDescriptor: repository,
}),
);
}
}
if (repositoryInfo != null) {
this._children.push(new MessageNode(this.view, this, repositoryInfo));
}
}
return this._children;
}
getTreeItem(): TreeItem {
const description = '';
// TODO@ramint Icon needs to change based on workspace type, and need a tooltip.
const icon: ThemeIcon = new ThemeIcon(this._type == WorkspaceType.Cloud ? 'cloud' : 'folder');
const item = new TreeItem(this.name, TreeItemCollapsibleState.Collapsed);
let contextValue = `${ContextValues.Workspace}`;
if (this._type === WorkspaceType.Cloud) {
contextValue += '+cloud';
} else {
contextValue += '+local';
}
item.id = this.id;
item.description = description;
item.contextValue = contextValue;
item.iconPath = icon;
item.tooltip = `${this.name}\n${
this._type === WorkspaceType.Cloud
? `Cloud Workspace ${this._workspace.isShared() ? '(Shared)' : ''}`
: 'Local Workspace'
}${
this._workspace instanceof GKCloudWorkspace && this._workspace.provider != null
? `\nProvider: ${this._workspace.provider}`
: ''
}`;
item.resourceUri = undefined;
return item;
}
override refresh() {
this._children = undefined;
}
}

+ 67
- 0
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<WorkspacesView> {
static key = ':workspaces';
static getId(): string {
return `gitlens${this.key}`;
}
private _children: (WorkspaceNode | MessageNode | RepositoriesNode)[] | undefined;
override get id(): string {
return WorkspacesViewNode.getId();
}
async getChildren(): Promise<ViewNode[]> {
if (this._children == null) {
const children: (WorkspaceNode | MessageNode | RepositoriesNode)[] = [];
const { cloudWorkspaces, cloudWorkspaceInfo, localWorkspaces, localWorkspaceInfo } =
await this.view.container.workspaces.getWorkspaces();
if (cloudWorkspaces.length || localWorkspaces.length) {
children.push(new RepositoriesNode(this.view));
for (const workspace of cloudWorkspaces) {
children.push(new WorkspaceNode(this.uri, this.view, this, workspace));
}
if (cloudWorkspaceInfo != null) {
children.push(new MessageNode(this.view, this, cloudWorkspaceInfo));
}
for (const workspace of localWorkspaces) {
children.push(new WorkspaceNode(this.uri, this.view, this, workspace));
}
if (cloudWorkspaces.length === 0 && cloudWorkspaceInfo == null) {
children.push(new MessageNode(this.view, this, 'No cloud workspaces found.'));
}
if (localWorkspaceInfo != null) {
children.push(new MessageNode(this.view, this, localWorkspaceInfo));
}
}
this._children = children;
}
return this._children;
}
getTreeItem(): TreeItem {
const item = new TreeItem('Workspaces', TreeItemCollapsibleState.Expanded);
return item;
}
override refresh() {
this._children = undefined;
void this.getChildren();
}
}

+ 12
- 4
src/views/nodes/worktreeNode.ts Переглянути файл

@ -32,8 +32,8 @@ type State = {
export class WorktreeNode extends ViewNode<ViewsWithWorktrees, State> {
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;

+ 9
- 4
src/views/nodes/worktreesNode.ts Переглянути файл

@ -13,8 +13,8 @@ import { WorktreeNode } from './worktreeNode';
export class WorktreesNode extends ViewNode<ViewsWithWorktreesNode> {
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;

+ 22
- 15
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<View, FileHistoryView | LineHistoryView | StashesView>;
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<View, FileHistoryView | LineHistoryView | RepositoriesView>;
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<T> extends TreeViewExpansionEvent<T> {
state: TreeItemCollapsibleState;
@ -99,6 +105,7 @@ export abstract class ViewBase<
| SearchAndCompareViewConfig
| StashesViewConfig
| TagsViewConfig
| WorkspacesViewConfig
| WorktreesViewConfig,
> implements TreeDataProvider<ViewNode>, Disposable
{

+ 24
- 0
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);

+ 209
- 0
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<WorkspacesViewNode, WorkspacesViewConfig> {
protected readonly configKey = 'repositories';
private _workspacesChangedDisposable: Disposable;
private _visibleDisposable: Disposable | undefined;
constructor(container: Container) {
super(container, 'gitlens.views.workspaces', 'Workspaces', 'workspaceView');
this._workspacesChangedDisposable = this.container.workspaces.onDidChangeWorkspaces(() => {
void this.ensureRoot().triggerChange(true);
});
}
protected override onVisibilityChanged(e: TreeViewVisibilityChangeEvent): void {
if (e.visible) {
void this.updateDescription();
this._visibleDisposable?.dispose();
this._visibleDisposable = this.container.subscription.onDidChange(() => void this.updateDescription());
} else {
this._visibleDisposable?.dispose();
this._visibleDisposable = undefined;
}
super.onVisibilityChanged(e);
}
override dispose() {
this._workspacesChangedDisposable.dispose();
this._visibleDisposable?.dispose();
super.dispose();
}
override get canSelectMany(): boolean {
return false;
}
protected getRoot() {
return new WorkspacesViewNode(unknownGitUri, this);
}
override async show(options?: { preserveFocus?: boolean | undefined }): Promise<void> {
if (!(await ensurePlusFeaturesEnabled())) return;
return super.show(options);
}
private async updateDescription() {
const subscription = await this.container.subscription.getSubscription();
this.description = subscription.state === SubscriptionState.Paid ? undefined : '✨';
}
override get canReveal(): boolean {
return false;
}
protected registerCommands(): Disposable[] {
void this.container.viewCommands;
return [
registerViewCommand(
this.getQualifiedCommand('refresh'),
() => {
this.container.workspaces.resetWorkspaces();
void this.ensureRoot().triggerChange(true);
},
this,
),
registerViewCommand(
this.getQualifiedCommand('convert'),
async (node: RepositoriesNode) => {
const repos: Repository[] = [];
for (const child of node.getChildren()) {
if (child instanceof RepositoryNode) {
repos.push(child.repo);
}
}
if (repos.length === 0) return;
await this.container.workspaces.createCloudWorkspace({ repos: repos });
void this.ensureRoot().triggerChange(true);
},
this,
),
registerViewCommand(
this.getQualifiedCommand('create'),
async () => {
await this.container.workspaces.createCloudWorkspace();
void this.ensureRoot().triggerChange(true);
},
this,
),
registerViewCommand(
this.getQualifiedCommand('open'),
async (node: WorkspaceNode) => {
await this.container.workspaces.saveAsCodeWorkspaceFile(node.workspaceId, node.type, {
open: true,
});
},
this,
),
registerViewCommand(
this.getQualifiedCommand('delete'),
async (node: WorkspaceNode) => {
await this.container.workspaces.deleteCloudWorkspace(node.workspaceId);
void node.getParent()?.triggerChange(true);
},
this,
),
registerViewCommand(
this.getQualifiedCommand('locateRepo'),
async (node: RepositoryNode | WorkspaceMissingRepositoryNode) => {
const descriptor = node.workspaceRepositoryDescriptor;
if (descriptor == null || node.workspaceId == null) return;
await this.container.workspaces.locateWorkspaceRepo(node.workspaceId, descriptor);
void node.getParent()?.triggerChange(true);
},
this,
),
registerViewCommand(
this.getQualifiedCommand('locateAllRepos'),
async (node: WorkspaceNode) => {
if (node.type !== WorkspaceType.Cloud) return;
await window.withProgress(
{
location: ProgressLocation.Notification,
title: `Locating Repositories for '${node.workspace.name}'...`,
cancellable: true,
},
(_progress, token) =>
this.container.workspaces.locateAllCloudWorkspaceRepos(node.workspaceId, token),
);
void node.triggerChange(true);
},
this,
),
registerViewCommand(
this.getQualifiedCommand('openRepoNewWindow'),
(node: RepositoryNode) => {
const workspaceNode = node.getParent();
if (workspaceNode == null || !(workspaceNode instanceof WorkspaceNode)) {
return;
}
openWorkspace(node.repo.uri, { location: OpenWorkspaceLocation.NewWindow });
},
this,
),
registerViewCommand(
this.getQualifiedCommand('openRepoCurrentWindow'),
(node: RepositoryNode) => {
const workspaceNode = node.getParent();
if (workspaceNode == null || !(workspaceNode instanceof WorkspaceNode)) {
return;
}
openWorkspace(node.repo.uri, { location: OpenWorkspaceLocation.CurrentWindow });
},
this,
),
registerViewCommand(
this.getQualifiedCommand('openRepoWorkspace'),
(node: RepositoryNode) => {
const workspaceNode = node.getParent();
if (workspaceNode == null || !(workspaceNode instanceof WorkspaceNode)) {
return;
}
openWorkspace(node.repo.uri, { location: OpenWorkspaceLocation.AddToWorkspace });
},
this,
),
registerViewCommand(this.getQualifiedCommand('addRepo'), async (node: WorkspaceNode) => {
await this.container.workspaces.addCloudWorkspaceRepo(node.workspaceId);
void node.getParent()?.triggerChange(true);
}),
registerViewCommand(
this.getQualifiedCommand('removeRepo'),
async (node: RepositoryNode | WorkspaceMissingRepositoryNode) => {
const descriptor = node.workspaceRepositoryDescriptor;
if (descriptor?.id == null || node.workspaceId == null) return;
await this.container.workspaces.removeCloudWorkspaceRepo(node.workspaceId, descriptor);
// TODO@axosoft-ramint Do we need the grandparent here?
void node.getParent()?.getParent()?.triggerChange(true);
},
),
];
}
}

Завантаження…
Відмінити
Зберегти