Browse Source

Improves linking between GK and Local Workspaces (#2761)

* Remembers link between workspaces and their code-workspace file

* Syncs repositories (add repos when new ones available in cloud)

* Additional naming updates

* Cleans up outdated paths on open and offers to locate

* Avoids overwriting other subfields when writing repo path
main
Ramin Tadayon 1 year ago
committed by GitHub
parent
commit
dc984ef228
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 520 additions and 103 deletions
  1. +51
    -6
      package.json
  2. +4
    -1
      src/constants.ts
  3. +18
    -3
      src/env/browser/pathMapping/workspacesWebPathMappingProvider.ts
  4. +3
    -1
      src/env/node/pathMapping/repositoryLocalPathMappingProvider.ts
  5. +107
    -24
      src/env/node/pathMapping/workspacesLocalPathMappingProvider.ts
  6. +104
    -17
      src/plus/workspaces/models.ts
  7. +18
    -3
      src/plus/workspaces/workspacesPathMappingProvider.ts
  8. +170
    -31
      src/plus/workspaces/workspacesService.ts
  9. +11
    -9
      src/views/nodes/workspaceNode.ts
  10. +10
    -0
      src/views/viewDecorationProvider.ts
  11. +24
    -8
      src/views/workspacesView.ts

+ 51
- 6
package.json View File

@ -4238,6 +4238,15 @@
}
},
{
"id": "gitlens.decorations.workspaceCurrentForegroundColor",
"description": "Specifies the decoration foreground color of workspaces which are currently open as a Code Workspace file",
"defaults": {
"dark": "#35b15e",
"light": "#35b15e",
"highContrast": "#4dff88"
}
},
{
"id": "gitlens.decorations.workspaceRepoOpenForegroundColor",
"description": "Specifies the decoration foreground color of workspace repos which are open in the current workspace",
"defaults": {
@ -6933,12 +6942,24 @@
"icon": "$(location)"
},
{
"command": "gitlens.views.workspaces.open",
"title": "Open as VS Code Workspace...",
"command": "gitlens.views.workspaces.createLocal",
"title": "Create VS Code Workspace...",
"category": "GitLens",
"icon": "$(empty-window)"
},
{
"command": "gitlens.views.workspaces.openLocal",
"title": "Open VS Code Workspace in Current Window...",
"category": "GitLens",
"icon": "$(window)"
},
{
"command": "gitlens.views.workspaces.openLocalNewWindow",
"title": "Open VS Code Workspace in New Window...",
"category": "GitLens",
"icon": "$(window)"
},
{
"command": "gitlens.views.workspaces.repo.locate",
"title": "Locate Repository...",
"category": "GitLens",
@ -9485,7 +9506,15 @@
"when": "false"
},
{
"command": "gitlens.views.workspaces.open",
"command": "gitlens.views.workspaces.createLocal",
"when": "false"
},
{
"command": "gitlens.views.workspaces.openLocal",
"when": "false"
},
{
"command": "gitlens.views.workspaces.openLocalNewWindow",
"when": "false"
},
{
@ -11117,11 +11146,17 @@
"group": "inline@2"
},
{
"command": "gitlens.views.workspaces.open",
"when": "viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+(cloud|local)\\b)/",
"command": "gitlens.views.workspaces.createLocal",
"when": "viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+(cloud|local)\\b)(?!.*?\\b\\+current\\b)(?!.*?\\b\\+hasPath\\b)/",
"group": "inline@3"
},
{
"command": "gitlens.views.workspaces.openLocalNewWindow",
"when": "viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+(cloud|local)\\b)(?!.*?\\b\\+current\\b)(?=.*?\\b\\+hasPath\\b)/",
"group": "inline@3",
"alt": "gitlens.views.workspaces.openLocal"
},
{
"command": "gitlens.views.workspaces.addRepos",
"when": "viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+cloud\\b)/",
"group": "1_gitlens_actions@1"
@ -11132,11 +11167,21 @@
"group": "1_gitlens_actions@2"
},
{
"command": "gitlens.views.workspaces.open",
"command": "gitlens.views.workspaces.createLocal",
"when": "viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+(cloud|local)\\b)/",
"group": "2_gitlens_quickopen@3"
},
{
"command": "gitlens.views.workspaces.openLocal",
"when": "viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+(cloud|local)\\b)(?!.*?\\b\\+current\\b)(?=.*?\\b\\+hasPath\\b)/",
"group": "2_gitlens_quickopen@4"
},
{
"command": "gitlens.views.workspaces.openLocalNewWindow",
"when": "viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+(cloud|local)\\b)(?!.*?\\b\\+current\\b)(?=.*?\\b\\+hasPath\\b)/",
"group": "2_gitlens_quickopen@5"
},
{
"command": "gitlens.views.workspaces.delete",
"when": "viewItem =~ /gitlens:workspace\\b(?=.*?\\b\\+cloud\\b)/",
"group": "6_gitlens_actions@1"

+ 4
- 1
src/constants.ts View File

@ -74,6 +74,7 @@ export type Colors =
| `${typeof extensionPrefix}.decorations.modifiedForegroundColor`
| `${typeof extensionPrefix}.decorations.renamedForegroundColor`
| `${typeof extensionPrefix}.decorations.untrackedForegroundColor`
| `${typeof extensionPrefix}.decorations.workspaceCurrentForegroundColor`
| `${typeof extensionPrefix}.decorations.workspaceRepoMissingForegroundColor`
| `${typeof extensionPrefix}.decorations.workspaceRepoOpenForegroundColor`
| `${typeof extensionPrefix}.decorations.worktreeView.hasUncommittedChangesForegroundColor`
@ -405,9 +406,11 @@ export type TreeViewCommands = `gitlens.views.${
| 'addRepos'
| 'convert'
| 'create'
| 'createLocal'
| 'delete'
| 'locateAllRepos'
| 'open'
| 'openLocal'
| 'openLocalNewWindow'
| `repo.${'locate' | 'open' | 'openInNewWindow' | 'addToWindow' | 'remove'}`}`
| `worktrees.${
| 'copy'

+ 18
- 3
src/env/browser/pathMapping/workspacesWebPathMappingProvider.ts View File

@ -1,5 +1,5 @@
import { Uri } from 'vscode';
import type { LocalWorkspaceFileData } from '../../../plus/workspaces/models';
import type { LocalWorkspaceFileData, WorkspaceSyncSetting } from '../../../plus/workspaces/models';
import type { WorkspacesPathMappingProvider } from '../../../plus/workspaces/workspacesPathMappingProvider';
export class WorkspacesWebPathMappingProvider implements WorkspacesPathMappingProvider {
@ -7,7 +7,22 @@ export class WorkspacesWebPathMappingProvider implements WorkspacesPathMappingPr
return undefined;
}
async writeCloudWorkspaceDiskPathToMap(
async getCloudWorkspaceCodeWorkspacePath(_cloudWorkspaceId: string): Promise<string | undefined> {
return undefined;
}
async removeCloudWorkspaceCodeWorkspaceFilePath(_cloudWorkspaceId: string): Promise<void> {}
async writeCloudWorkspaceCodeWorkspaceFilePathToMap(
_cloudWorkspaceId: string,
_codeWorkspaceFilePath: string,
): Promise<void> {}
async confirmCloudWorkspaceCodeWorkspaceFilePath(_cloudWorkspaceId: string): Promise<boolean> {
return false;
}
async writeCloudWorkspaceRepoDiskPathToMap(
_cloudWorkspaceId: string,
_repoId: string,
_repoLocalPath: string,
@ -20,7 +35,7 @@ export class WorkspacesWebPathMappingProvider implements WorkspacesPathMappingPr
async writeCodeWorkspaceFile(
_uri: Uri,
_workspaceRepoFilePaths: string[],
_options?: { workspaceId?: string },
_options?: { workspaceId?: string; workspaceSyncSetting?: WorkspaceSyncSetting },
): Promise<boolean> {
return false;
}

+ 3
- 1
src/env/node/pathMapping/repositoryLocalPathMappingProvider.ts View File

@ -93,8 +93,10 @@ export class RepositoryLocalPathMappingProvider implements RepositoryPathMapping
this._localRepoDataMap = {};
}
if (this._localRepoDataMap[key] == null || this._localRepoDataMap[key].paths == null) {
if (this._localRepoDataMap[key] == null) {
this._localRepoDataMap[key] = { paths: [localPath] };
} else if (this._localRepoDataMap[key].paths == null) {
this._localRepoDataMap[key].paths = [localPath];
} else if (!this._localRepoDataMap[key].paths.includes(localPath)) {
this._localRepoDataMap[key].paths.push(localPath);
}

+ 107
- 24
src/env/node/pathMapping/workspacesLocalPathMappingProvider.ts View File

@ -1,9 +1,9 @@
import type { Uri } from 'vscode';
import { workspace } from 'vscode';
import { Uri, workspace } from 'vscode';
import type {
CloudWorkspacesPathMap,
CodeWorkspaceFileContents,
LocalWorkspaceFileData,
WorkspaceSyncSetting,
} from '../../../plus/workspaces/models';
import type { WorkspacesPathMappingProvider } from '../../../plus/workspaces/workspacesPathMappingProvider';
import { Logger } from '../../../system/logger';
@ -16,35 +16,73 @@ import {
} from './sharedGKDataFolder';
export class WorkspacesLocalPathMappingProvider implements WorkspacesPathMappingProvider {
private _cloudWorkspaceRepoPathMap: CloudWorkspacesPathMap | undefined = undefined;
private _cloudWorkspacePathMap: CloudWorkspacesPathMap | undefined = undefined;
private async ensureCloudWorkspaceRepoPathMap(): Promise<void> {
if (this._cloudWorkspaceRepoPathMap == null) {
await this.loadCloudWorkspaceRepoPathMap();
private async ensureCloudWorkspacePathMap(): Promise<void> {
if (this._cloudWorkspacePathMap == null) {
await this.loadCloudWorkspacePathMap();
}
}
private async getCloudWorkspaceRepoPathMap(): Promise<CloudWorkspacesPathMap> {
await this.ensureCloudWorkspaceRepoPathMap();
return this._cloudWorkspaceRepoPathMap ?? {};
private async getCloudWorkspacePathMap(): Promise<CloudWorkspacesPathMap> {
await this.ensureCloudWorkspacePathMap();
return this._cloudWorkspacePathMap ?? {};
}
private async loadCloudWorkspaceRepoPathMap(): Promise<void> {
private async loadCloudWorkspacePathMap(): Promise<void> {
const localFileUri = getSharedCloudWorkspaceMappingFileUri();
try {
const data = await workspace.fs.readFile(localFileUri);
this._cloudWorkspaceRepoPathMap = (JSON.parse(data.toString())?.workspaces ?? {}) as CloudWorkspacesPathMap;
this._cloudWorkspacePathMap = (JSON.parse(data.toString())?.workspaces ?? {}) as CloudWorkspacesPathMap;
} catch (error) {
Logger.error(error, 'loadCloudWorkspaceRepoPathMap');
Logger.error(error, 'loadCloudWorkspacePathMap');
}
}
async getCloudWorkspaceRepoPath(cloudWorkspaceId: string, repoId: string): Promise<string | undefined> {
const cloudWorkspaceRepoPathMap = await this.getCloudWorkspaceRepoPathMap();
return cloudWorkspaceRepoPathMap[cloudWorkspaceId]?.repoPaths[repoId];
const cloudWorkspacePathMap = await this.getCloudWorkspacePathMap();
return cloudWorkspacePathMap[cloudWorkspaceId]?.repoPaths?.[repoId];
}
async writeCloudWorkspaceDiskPathToMap(
async getCloudWorkspaceCodeWorkspacePath(cloudWorkspaceId: string): Promise<string | undefined> {
const cloudWorkspacePathMap = await this.getCloudWorkspacePathMap();
return cloudWorkspacePathMap[cloudWorkspaceId]?.externalLinks?.['.code-workspace'];
}
async removeCloudWorkspaceCodeWorkspaceFilePath(cloudWorkspaceId: string): Promise<void> {
if (!(await acquireSharedFolderWriteLock())) {
return;
}
await this.loadCloudWorkspacePathMap();
if (this._cloudWorkspacePathMap?.[cloudWorkspaceId]?.externalLinks?.['.code-workspace'] == null) return;
delete this._cloudWorkspacePathMap[cloudWorkspaceId].externalLinks['.code-workspace'];
const localFileUri = getSharedCloudWorkspaceMappingFileUri();
const outputData = new Uint8Array(Buffer.from(JSON.stringify({ workspaces: this._cloudWorkspacePathMap })));
try {
await workspace.fs.writeFile(localFileUri, outputData);
} catch (error) {
Logger.error(error, 'writeCloudWorkspaceCodeWorkspaceFilePathToMap');
}
await releaseSharedFolderWriteLock();
}
async confirmCloudWorkspaceCodeWorkspaceFilePath(cloudWorkspaceId: string): Promise<boolean> {
const cloudWorkspacePathMap = await this.getCloudWorkspacePathMap();
const codeWorkspaceFilePath = cloudWorkspacePathMap[cloudWorkspaceId]?.externalLinks?.['.code-workspace'];
if (codeWorkspaceFilePath == null) return false;
try {
await workspace.fs.stat(Uri.file(codeWorkspaceFilePath));
return true;
} catch {
return false;
}
}
async writeCloudWorkspaceRepoDiskPathToMap(
cloudWorkspaceId: string,
repoId: string,
repoLocalPath: string,
@ -53,24 +91,62 @@ export class WorkspacesLocalPathMappingProvider implements WorkspacesPathMapping
return;
}
await this.loadCloudWorkspaceRepoPathMap();
await this.loadCloudWorkspacePathMap();
if (this._cloudWorkspaceRepoPathMap == null) {
this._cloudWorkspaceRepoPathMap = {};
if (this._cloudWorkspacePathMap == null) {
this._cloudWorkspacePathMap = {};
}
if (this._cloudWorkspaceRepoPathMap[cloudWorkspaceId] == null) {
this._cloudWorkspaceRepoPathMap[cloudWorkspaceId] = { repoPaths: {} };
if (this._cloudWorkspacePathMap[cloudWorkspaceId] == null) {
this._cloudWorkspacePathMap[cloudWorkspaceId] = { repoPaths: {}, externalLinks: {} };
}
this._cloudWorkspaceRepoPathMap[cloudWorkspaceId].repoPaths[repoId] = repoLocalPath;
if (this._cloudWorkspacePathMap[cloudWorkspaceId].repoPaths == null) {
this._cloudWorkspacePathMap[cloudWorkspaceId].repoPaths = {};
}
this._cloudWorkspacePathMap[cloudWorkspaceId].repoPaths[repoId] = repoLocalPath;
const localFileUri = getSharedCloudWorkspaceMappingFileUri();
const outputData = new Uint8Array(Buffer.from(JSON.stringify({ workspaces: this._cloudWorkspaceRepoPathMap })));
const outputData = new Uint8Array(Buffer.from(JSON.stringify({ workspaces: this._cloudWorkspacePathMap })));
try {
await workspace.fs.writeFile(localFileUri, outputData);
} catch (error) {
Logger.error(error, 'writeCloudWorkspaceDiskPathToMap');
Logger.error(error, 'writeCloudWorkspaceRepoDiskPathToMap');
}
await releaseSharedFolderWriteLock();
}
async writeCloudWorkspaceCodeWorkspaceFilePathToMap(
cloudWorkspaceId: string,
codeWorkspaceFilePath: string,
): Promise<void> {
if (!(await acquireSharedFolderWriteLock())) {
return;
}
await this.loadCloudWorkspacePathMap();
if (this._cloudWorkspacePathMap == null) {
this._cloudWorkspacePathMap = {};
}
if (this._cloudWorkspacePathMap[cloudWorkspaceId] == null) {
this._cloudWorkspacePathMap[cloudWorkspaceId] = { repoPaths: {}, externalLinks: {} };
}
if (this._cloudWorkspacePathMap[cloudWorkspaceId].externalLinks == null) {
this._cloudWorkspacePathMap[cloudWorkspaceId].externalLinks = {};
}
this._cloudWorkspacePathMap[cloudWorkspaceId].externalLinks['.code-workspace'] = codeWorkspaceFilePath;
const localFileUri = getSharedCloudWorkspaceMappingFileUri();
const outputData = new Uint8Array(Buffer.from(JSON.stringify({ workspaces: this._cloudWorkspacePathMap })));
try {
await workspace.fs.writeFile(localFileUri, outputData);
} catch (error) {
Logger.error(error, 'writeCloudWorkspaceCodeWorkspaceFilePathToMap');
}
await releaseSharedFolderWriteLock();
}
@ -102,7 +178,7 @@ export class WorkspacesLocalPathMappingProvider implements WorkspacesPathMapping
async writeCodeWorkspaceFile(
uri: Uri,
workspaceRepoFilePaths: string[],
options?: { workspaceId?: string },
options?: { workspaceId?: string; workspaceSyncSetting?: WorkspaceSyncSetting },
): Promise<boolean> {
let codeWorkspaceFileContents: CodeWorkspaceFileContents;
let data;
@ -118,9 +194,16 @@ export class WorkspacesLocalPathMappingProvider implements WorkspacesPathMapping
codeWorkspaceFileContents.settings['gitkraken.workspaceId'] = options.workspaceId;
}
if (options?.workspaceSyncSetting != null) {
codeWorkspaceFileContents.settings['gitkraken.workspaceSyncSetting'] = options.workspaceSyncSetting;
}
const outputData = new Uint8Array(Buffer.from(JSON.stringify(codeWorkspaceFileContents)));
try {
await workspace.fs.writeFile(uri, outputData);
if (options?.workspaceId != null) {
await this.writeCloudWorkspaceCodeWorkspaceFilePathToMap(options.workspaceId, uri.fsPath);
}
} catch (error) {
Logger.error(error, 'writeCodeWorkspaceFile');
return false;

+ 104
- 17
src/plus/workspaces/models.ts View File

@ -1,3 +1,4 @@
import type { Disposable } from '../../api/gitlens';
import type { Container } from '../../container';
import type { Repository } from '../../git/models/repository';
@ -6,6 +7,12 @@ export enum WorkspaceType {
Cloud = 'cloud',
}
export enum WorkspaceSyncSetting {
Never = 'never',
Always = 'always',
Ask = 'ask',
}
export type CodeWorkspaceFileContents = {
folders: { path: string }[];
settings: { [key: string]: any };
@ -50,7 +57,10 @@ export interface GetCloudWorkspaceRepositoriesResponse {
export class CloudWorkspace {
readonly type = WorkspaceType.Cloud;
private _repositories: CloudWorkspaceRepositoryDescriptor[] | undefined;
private _repositoryDescriptors: CloudWorkspaceRepositoryDescriptor[] | undefined;
private _repositoriesByName: WorkspaceRepositoriesByName | undefined;
private _localPath: string | undefined;
private _disposable: Disposable;
constructor(
private readonly container: Container,
@ -58,21 +68,49 @@ export class CloudWorkspace {
public readonly name: string,
public readonly organizationId: string | undefined,
public readonly provider: CloudWorkspaceProviderType,
public readonly current: boolean,
repositories?: CloudWorkspaceRepositoryDescriptor[],
localPath?: string,
) {
this._repositories = repositories;
this._repositoryDescriptors = repositories;
this._localPath = localPath;
this._disposable = this.container.git.onDidChangeRepositories(this.resetRepositoriesByName, this);
}
dispose() {
this._disposable.dispose();
}
get shared(): boolean {
return this.organizationId != null;
}
async getRepositoryDescriptors(): Promise<CloudWorkspaceRepositoryDescriptor[]> {
if (this._repositories == null) {
this._repositories = await this.container.workspaces.getCloudWorkspaceRepositories(this.id);
get localPath(): string | undefined {
return this._localPath;
}
resetRepositoriesByName() {
this._repositoriesByName = undefined;
}
async getRepositoriesByName(options?: { force?: boolean }): Promise<WorkspaceRepositoriesByName> {
if (this._repositoriesByName == null || options?.force) {
this._repositoriesByName = await this.container.workspaces.resolveWorkspaceRepositoriesByName(this.id, {
resolveFromPath: true,
usePathMapping: true,
});
}
return this._repositories;
return this._repositoriesByName;
}
async getRepositoryDescriptors(options?: { force?: boolean }): Promise<CloudWorkspaceRepositoryDescriptor[]> {
if (this._repositoryDescriptors == null || options?.force) {
this._repositoryDescriptors = await this.container.workspaces.getCloudWorkspaceRepositories(this.id);
this.resetRepositoriesByName();
}
return this._repositoryDescriptors;
}
async getRepositoryDescriptor(name: string): Promise<CloudWorkspaceRepositoryDescriptor | undefined> {
@ -81,18 +119,25 @@ export class CloudWorkspace {
// TODO@axosoft-ramint this should be the entry point, not a backdoor to update the cache
addRepositories(repositories: CloudWorkspaceRepositoryDescriptor[]): void {
if (this._repositories == null) {
this._repositories = repositories;
if (this._repositoryDescriptors == null) {
this._repositoryDescriptors = repositories;
} else {
this._repositories = this._repositories.concat(repositories);
this._repositoryDescriptors = this._repositoryDescriptors.concat(repositories);
}
this.resetRepositoriesByName();
}
// TODO@axosoft-ramint this should be the entry point, not a backdoor to update the cache
removeRepositories(repoNames: string[]): void {
if (this._repositories == null) return;
if (this._repositoryDescriptors == null) return;
this._repositoryDescriptors = this._repositoryDescriptors.filter(r => !repoNames.includes(r.name));
this.resetRepositoriesByName();
}
this._repositories = this._repositories.filter(r => !repoNames.includes(r.name));
setLocalPath(localPath: string | undefined): void {
this._localPath = localPath;
}
}
@ -468,22 +513,59 @@ export interface RemoveWorkspaceRepoDescriptor {
export class LocalWorkspace {
readonly type = WorkspaceType.Local;
private _localPath: string | undefined;
private _repositoriesByName: WorkspaceRepositoriesByName | undefined;
private _disposable: Disposable;
constructor(
public readonly container: Container,
public readonly id: string,
public readonly name: string,
private readonly repositories: LocalWorkspaceRepositoryDescriptor[],
) {}
private readonly repositoryDescriptors: LocalWorkspaceRepositoryDescriptor[],
public readonly current: boolean,
localPath?: string,
) {
this._localPath = localPath;
this._disposable = this.container.git.onDidChangeRepositories(this.resetRepositoriesByName, this);
}
dispose() {
this._disposable.dispose();
}
get shared(): boolean {
return false;
}
get localPath(): string | undefined {
return this._localPath;
}
resetRepositoriesByName() {
this._repositoriesByName = undefined;
}
async getRepositoriesByName(options?: { force?: boolean }): Promise<WorkspaceRepositoriesByName> {
if (this._repositoriesByName == null || options?.force) {
this._repositoriesByName = await this.container.workspaces.resolveWorkspaceRepositoriesByName(this.id, {
resolveFromPath: true,
usePathMapping: true,
});
}
return this._repositoriesByName;
}
getRepositoryDescriptors(): Promise<LocalWorkspaceRepositoryDescriptor[]> {
return Promise.resolve(this.repositories);
return Promise.resolve(this.repositoryDescriptors);
}
getRepositoryDescriptor(name: string): Promise<LocalWorkspaceRepositoryDescriptor | undefined> {
return Promise.resolve(this.repositories.find(r => r.name === name));
return Promise.resolve(this.repositoryDescriptors.find(r => r.name === name));
}
setLocalPath(localPath: string | undefined): void {
this._localPath = localPath;
}
}
@ -519,13 +601,18 @@ export interface CloudWorkspaceFileData {
}
export type CloudWorkspacesPathMap = {
[cloudWorkspaceId: string]: CloudWorkspaceRepoPaths;
[cloudWorkspaceId: string]: CloudWorkspacePaths;
};
export interface CloudWorkspaceRepoPaths {
export interface CloudWorkspacePaths {
repoPaths: CloudWorkspaceRepoPathMap;
externalLinks: CloudWorkspaceExternalLinkMap;
}
export type CloudWorkspaceRepoPathMap = {
[repoId: string]: string;
};
export type CloudWorkspaceExternalLinkMap = {
[fileExtenstion: string]: string;
};

+ 18
- 3
src/plus/workspaces/workspacesPathMappingProvider.ts View File

@ -1,16 +1,31 @@
import type { Uri } from 'vscode';
import type { LocalWorkspaceFileData } from './models';
import type { LocalWorkspaceFileData, WorkspaceSyncSetting } from './models';
export interface WorkspacesPathMappingProvider {
getCloudWorkspaceRepoPath(cloudWorkspaceId: string, repoId: string): Promise<string | undefined>;
writeCloudWorkspaceDiskPathToMap(cloudWorkspaceId: string, repoId: string, repoLocalPath: string): Promise<void>;
getCloudWorkspaceCodeWorkspacePath(cloudWorkspaceId: string): Promise<string | undefined>;
removeCloudWorkspaceCodeWorkspaceFilePath(cloudWorkspaceId: string): Promise<void>;
writeCloudWorkspaceCodeWorkspaceFilePathToMap(
cloudWorkspaceId: string,
codeWorkspaceFilePath: string,
): Promise<void>;
confirmCloudWorkspaceCodeWorkspaceFilePath(cloudWorkspaceId: string): Promise<boolean>;
writeCloudWorkspaceRepoDiskPathToMap(
cloudWorkspaceId: string,
repoId: string,
repoLocalPath: string,
): Promise<void>;
getLocalWorkspaceData(): Promise<LocalWorkspaceFileData>;
writeCodeWorkspaceFile(
uri: Uri,
workspaceRepoFilePaths: string[],
options?: { workspaceId?: string },
options?: { workspaceId?: string; workspaceSyncSetting?: WorkspaceSyncSetting },
): Promise<boolean>;
}

+ 170
- 31
src/plus/workspaces/workspacesService.ts View File

@ -1,5 +1,5 @@
import type { CancellationToken, Event } from 'vscode';
import { Disposable, EventEmitter, ProgressLocation, Uri, window } from 'vscode';
import { Disposable, EventEmitter, ProgressLocation, Uri, window, workspace } from 'vscode';
import { getSupportedWorkspacesPathMappingProvider } from '@env/providers';
import type { Container } from '../../container';
import type { GitRemote } from '../../git/models/remote';
@ -32,15 +32,15 @@ import {
cloudWorkspaceProviderTypeToRemoteProviderId,
LocalWorkspace,
WorkspaceAddRepositoriesChoice,
WorkspaceType,
WorkspaceSyncSetting,
} from './models';
import { WorkspacesApi } from './workspacesApi';
import type { WorkspacesPathMappingProvider } from './workspacesPathMappingProvider';
export class WorkspacesService implements Disposable {
private _onDidChangeWorkspaces: EventEmitter<void> = new EventEmitter<void>();
get onDidChangeWorkspaces(): Event<void> {
return this._onDidChangeWorkspaces.event;
private _onDidResetWorkspaces: EventEmitter<void> = new EventEmitter<void>();
get onDidResetWorkspaces(): Event<void> {
return this._onDidResetWorkspaces.event;
}
private _cloudWorkspaces: CloudWorkspace[] | undefined;
@ -48,10 +48,16 @@ export class WorkspacesService implements Disposable {
private _localWorkspaces: LocalWorkspace[] | undefined;
private _workspacesApi: WorkspacesApi;
private _workspacesPathProvider: WorkspacesPathMappingProvider;
private _currentWorkspaceId: string | undefined;
private _currentWorkspaceSyncSetting: WorkspaceSyncSetting = WorkspaceSyncSetting.Never;
constructor(private readonly container: Container, private readonly server: ServerConnection) {
this._workspacesApi = new WorkspacesApi(this.container, this.server);
this._workspacesPathProvider = getSupportedWorkspacesPathMappingProvider();
this._currentWorkspaceId = workspace.getConfiguration('gitkraken')?.get<string>('workspaceId');
this._currentWorkspaceSyncSetting =
workspace.getConfiguration('gitkraken')?.get<WorkspaceSyncSetting>('workspaceSyncSetting') ??
WorkspaceSyncSetting.Never;
this._disposable = Disposable.from(container.subscription.onDidChange(this.onSubscriptionChanged, this));
}
@ -66,7 +72,6 @@ export class WorkspacesService implements Disposable {
event.current.state !== event.previous?.state
) {
this.resetWorkspaces({ cloud: true });
this._onDidChangeWorkspaces.fire();
}
}
@ -101,6 +106,7 @@ export class WorkspacesService implements Disposable {
if (workspaces?.length) {
for (const workspace of workspaces) {
const localPath = await this._workspacesPathProvider.getCloudWorkspaceCodeWorkspacePath(workspace.id);
if (!isPlusEnabled && workspace.organization?.id) {
filteredSharedWorkspaceCount += 1;
continue;
@ -122,7 +128,9 @@ export class WorkspacesService implements Disposable {
workspace.name,
workspace.organization?.id,
workspace.provider as CloudWorkspaceProviderType,
this._currentWorkspaceId != null && this._currentWorkspaceId === workspace.id,
repositories,
localPath,
),
);
}
@ -145,6 +153,7 @@ export class WorkspacesService implements Disposable {
for (const workspace of Object.values(workspaceFileData)) {
localWorkspaces.push(
new LocalWorkspace(
this.container,
workspace.localId,
workspace.name,
workspace.repositories.map(repositoryPath => ({
@ -152,6 +161,7 @@ export class WorkspacesService implements Disposable {
name: repositoryPath.localPath.split(/[\\/]/).pop() ?? 'unknown',
workspaceId: workspace.localId,
})),
this._currentWorkspaceId != null && this._currentWorkspaceId === workspace.localId,
),
);
}
@ -190,6 +200,14 @@ export class WorkspacesService implements Disposable {
getWorkspacesResponse.localWorkspaceInfo = loadLocalWorkspacesResponse.localWorkspaceInfo;
}
const currentWorkspace = [...(this._cloudWorkspaces ?? []), ...(this._localWorkspaces ?? [])].find(
workspace => workspace.current,
);
if (currentWorkspace != null) {
await this.syncCurrentWorkspace(currentWorkspace);
}
getWorkspacesResponse.cloudWorkspaces = this._cloudWorkspaces ?? [];
getWorkspacesResponse.localWorkspaces = this._localWorkspaces ?? [];
@ -203,6 +221,47 @@ export class WorkspacesService implements Disposable {
return descriptors?.map(d => ({ ...d, workspaceId: workspaceId })) ?? [];
}
async syncCurrentWorkspace(workspace: CloudWorkspace | LocalWorkspace): Promise<void> {
if (this._currentWorkspaceSyncSetting === WorkspaceSyncSetting.Never || !workspace.current) return;
if (!(await workspace.getRepositoryDescriptors())?.length) return;
const repositories = [...(await workspace.getRepositoriesByName()).values()].map(r => r.repository);
const currentWorkspaceRepositoryIdMap = new Map<string, Repository>();
for (const repository of this.container.git.openRepositories) {
currentWorkspaceRepositoryIdMap.set(repository.id, repository);
}
const repositoriesToAdd = repositories.filter(r => !currentWorkspaceRepositoryIdMap.has(r.id));
if (repositoriesToAdd.length === 0) return;
let chosenRepoPaths: string[] = [];
if (this._currentWorkspaceSyncSetting === WorkspaceSyncSetting.Ask) {
const addChoice = await window.showInformationMessage(
'New repositories found in the cloud workspace matching this workspace. Would you like to add them?',
{ modal: true },
{ title: 'Add' },
{ title: 'Cancel', isCloseAffordance: true },
);
if (addChoice?.title !== 'Add') {
return;
}
const pick = await showRepositoriesPicker(
'Add Repositories to Workspace',
'Choose which repositories to add to the current workspace',
repositoriesToAdd,
);
if (pick.length === 0) return;
chosenRepoPaths = pick.map(p => p.repoPath);
} else {
chosenRepoPaths = repositoriesToAdd.map(r => r.path);
}
for (const path of chosenRepoPaths) {
openWorkspace(Uri.file(path), { location: OpenWorkspaceLocation.AddToWorkspace });
}
}
resetWorkspaces(options?: { cloud?: boolean; local?: boolean }) {
if (options?.cloud ?? true) {
this._cloudWorkspaces = undefined;
@ -210,6 +269,8 @@ export class WorkspacesService implements Disposable {
if (options?.local ?? true) {
this._localWorkspaces = undefined;
}
this._onDidResetWorkspaces.fire();
}
async getCloudWorkspaceRepoPath(cloudWorkspaceId: string, repoId: string): Promise<string | undefined> {
@ -217,7 +278,7 @@ export class WorkspacesService implements Disposable {
}
async updateCloudWorkspaceRepoLocalPath(workspaceId: string, repoId: string, localPath: string): Promise<void> {
await this._workspacesPathProvider.writeCloudWorkspaceDiskPathToMap(workspaceId, repoId, localPath);
await this._workspacesPathProvider.writeCloudWorkspaceRepoDiskPathToMap(workspaceId, repoId, localPath);
}
private async getRepositoriesInParentFolder(cancellation?: CancellationToken): Promise<Repository[] | undefined> {
@ -551,6 +612,10 @@ export class WorkspacesService implements Disposable {
this._cloudWorkspaces = [];
}
const localPath = await this._workspacesPathProvider.getCloudWorkspaceCodeWorkspacePath(
createdProjectData.id,
);
this._cloudWorkspaces?.push(
new CloudWorkspace(
this.container,
@ -558,7 +623,9 @@ export class WorkspacesService implements Disposable {
createdProjectData.name,
createdProjectData.organization?.id,
createdProjectData.provider as CloudWorkspaceProviderType,
this._currentWorkspaceId != null && this._currentWorkspaceId === createdProjectData.id,
[],
localPath,
),
);
@ -607,14 +674,9 @@ export class WorkspacesService implements Disposable {
}
private async filterReposForCloudWorkspace(repos: Repository[], workspaceId: string): Promise<Repository[]> {
const workspaceRepos = [
...(
await this.resolveWorkspaceRepositoriesByName(workspaceId, {
resolveFromPath: true,
usePathMapping: true,
})
).values(),
].map(match => match.repository);
const workspace = this.getCloudWorkspace(workspaceId) ?? this.getLocalWorkspace(workspaceId);
if (workspace == null) return repos;
const workspaceRepos = [...(await workspace.getRepositoriesByName()).values()].map(match => match.repository);
return repos.filter(repo => !workspaceRepos.find(r => r.id === repo.id));
}
@ -843,6 +905,7 @@ export class WorkspacesService implements Disposable {
if (repoLocalPath != null && foundRepo == null && options?.resolveFromPath === true) {
foundRepo = await this.container.git.getOrOpenRepository(Uri.file(repoLocalPath), {
closeOnOpen: true,
force: true,
});
// 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.
@ -872,24 +935,14 @@ export class WorkspacesService implements Disposable {
return workspaceRepositoriesByName;
}
async saveAsCodeWorkspaceFile(
workspaceId: string,
workspaceType: WorkspaceType,
options?: { open?: boolean },
): Promise<void> {
const workspace =
workspaceType === WorkspaceType.Cloud
? this.getCloudWorkspace(workspaceId)
: this.getLocalWorkspace(workspaceId);
async saveAsCodeWorkspaceFile(workspaceId: string): Promise<void> {
const workspace = this.getCloudWorkspace(workspaceId) ?? this.getLocalWorkspace(workspaceId);
if (workspace == null) return;
const repoDescriptors = await workspace.getRepositoryDescriptors();
if (repoDescriptors == null) return;
const workspaceRepositoriesByName = await this.resolveWorkspaceRepositoriesByName(workspaceId, {
resolveFromPath: true,
usePathMapping: true,
});
const workspaceRepositoriesByName = await workspace.getRepositoriesByName();
if (workspaceRepositoriesByName.size === 0) {
void window.showErrorMessage('No repositories could be found in this workspace.', { modal: true });
@ -925,10 +978,21 @@ export class WorkspacesService implements Disposable {
if (newWorkspaceUri == null) return;
const newWorkspaceSyncSetting = await window.showInformationMessage(
'Would you like to sync your new workspace file with its cloud workspace?',
{ modal: true },
{ title: 'Always', option: WorkspaceSyncSetting.Always },
{ title: 'Never', option: WorkspaceSyncSetting.Never },
{ title: 'Ask every time', option: WorkspaceSyncSetting.Ask },
);
const created = await this._workspacesPathProvider.writeCodeWorkspaceFile(
newWorkspaceUri,
workspaceFolderPaths,
{ workspaceId: workspaceId },
{
workspaceId: workspaceId,
workspaceSyncSetting: newWorkspaceSyncSetting?.option ?? WorkspaceSyncSetting.Never,
},
);
if (!created) {
@ -936,9 +1000,84 @@ export class WorkspacesService implements Disposable {
return;
}
if (options?.open) {
openWorkspace(newWorkspaceUri, { location: OpenWorkspaceLocation.NewWindow });
workspace.setLocalPath(newWorkspaceUri.fsPath);
const open = await window.showInformationMessage(
`Workspace file created for ${workspace.name}. Would you like to open it now?`,
{ modal: true },
{ title: 'Open in New Window', location: OpenWorkspaceLocation.NewWindow },
{ title: 'Open in Current Window', location: OpenWorkspaceLocation.CurrentWindow },
{ title: 'Cancel', isCloseAffordance: true },
);
if (open == null || open.title == 'Cancel') return;
void this.openCodeWorkspaceFile(workspaceId, { location: open.location });
}
async openCodeWorkspaceFile(workspaceId: string, options?: { location?: OpenWorkspaceLocation }): Promise<void> {
const workspace = this.getCloudWorkspace(workspaceId) ?? this.getLocalWorkspace(workspaceId);
if (workspace == null) return;
if (workspace.localPath == null) {
const create = await window.showInformationMessage(
`The workspace file for ${workspace.name} has not been created. Would you like to create it now?`,
{ modal: true },
{ title: 'Create' },
{ title: 'Cancel', isCloseAffordance: true },
);
if (create == null || create.title == 'Cancel') return;
return void this.saveAsCodeWorkspaceFile(workspaceId);
}
let openLocation =
options?.location === OpenWorkspaceLocation.CurrentWindow
? OpenWorkspaceLocation.CurrentWindow
: OpenWorkspaceLocation.NewWindow;
if (!options?.location) {
const openLocationChoice = await window.showInformationMessage(
`How would you like to open the workspace file for ${workspace.name}?`,
{ modal: true },
{ title: 'Open in New Window', location: OpenWorkspaceLocation.NewWindow },
{ title: 'Open in Current Window', location: OpenWorkspaceLocation.CurrentWindow },
{ title: 'Cancel', isCloseAffordance: true },
);
if (openLocationChoice == null || openLocationChoice.title == 'Cancel') return;
openLocation = openLocationChoice.location ?? OpenWorkspaceLocation.NewWindow;
}
if (!(await this._workspacesPathProvider.confirmCloudWorkspaceCodeWorkspaceFilePath(workspace.id))) {
await this._workspacesPathProvider.removeCloudWorkspaceCodeWorkspaceFilePath(workspace.id);
workspace.setLocalPath(undefined);
const locateChoice = await window.showInformationMessage(
`The workspace file for ${workspace.name} could not be found. Would you like to locate it now?`,
{ modal: true },
{ title: 'Locate' },
{ title: 'Cancel', isCloseAffordance: true },
);
if (locateChoice?.title !== 'Locate') return;
const newPath = (
await window.showOpenDialog({
defaultUri: Uri.file(workspace.localPath),
canSelectFiles: true,
canSelectFolders: false,
canSelectMany: false,
filters: {
'Code Workspace': ['code-workspace'],
},
title: 'Locate the workspace file',
})
)?.[0]?.fsPath;
if (newPath == null) return;
await this._workspacesPathProvider.writeCloudWorkspaceCodeWorkspaceFilePathToMap(workspace.id, newPath);
workspace.setLocalPath(newPath);
}
openWorkspace(Uri.file(workspace.localPath), { location: openLocation });
}
private async getMappedPathForCloudWorkspaceRepoDescriptor(

+ 11
- 9
src/views/nodes/workspaceNode.ts View File

@ -1,6 +1,6 @@
import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode';
import { ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri } from 'vscode';
import { GitUri } from '../../git/gitUri';
import type { CloudWorkspace, LocalWorkspace, WorkspaceRepositoriesByName } from '../../plus/workspaces/models';
import type { CloudWorkspace, LocalWorkspace } from '../../plus/workspaces/models';
import { WorkspaceType } from '../../plus/workspaces/models';
import { createCommand } from '../../system/command';
import type { WorkspacesView } from '../workspacesView';
@ -55,12 +55,7 @@ export class WorkspaceNode extends ViewNode {
return this._children;
}
// TODO@eamodio this should not be done here -- it should be done in the workspaces model (when loading the repos)
const reposByName: WorkspaceRepositoriesByName =
await this.view.container.workspaces.resolveWorkspaceRepositoriesByName(this.workspace.id, {
resolveFromPath: true,
usePathMapping: true,
});
const reposByName = await this.workspace.getRepositoriesByName({ force: true });
for (const descriptor of descriptors) {
const repo = reposByName.get(descriptor.name)?.repository;
@ -93,11 +88,19 @@ export class WorkspaceNode extends ViewNode {
const item = new TreeItem(this.workspace.name, TreeItemCollapsibleState.Collapsed);
let contextValue = `${ContextValues.Workspace}`;
item.resourceUri = undefined;
if (this.workspace.type === WorkspaceType.Cloud) {
contextValue += '+cloud';
} else {
contextValue += '+local';
}
if (this.workspace.current) {
contextValue += '+current';
item.resourceUri = Uri.parse('gitlens-view://workspaces/workspace/current');
}
if (this.workspace.localPath != null) {
contextValue += '+hasPath';
}
item.id = this.id;
item.contextValue = contextValue;
item.iconPath = new ThemeIcon(this.workspace.type == WorkspaceType.Cloud ? 'cloud' : 'folder');
@ -110,7 +113,6 @@ export class WorkspaceNode extends ViewNode {
? `\nProvider: ${this.workspace.provider}`
: ''
}`;
item.resourceUri = undefined;
return item;
}

+ 10
- 0
src/views/viewDecorationProvider.ts View File

@ -62,6 +62,16 @@ export class ViewFileDecorationProvider implements FileDecorationProvider, Dispo
}
}
if (type === 'workspace') {
if (status === 'current') {
return {
badge: '●',
color: new ThemeColor('gitlens.decorations.workspaceCurrentForegroundColor' satisfies Colors),
tooltip: '',
};
}
}
return undefined;
}

+ 24
- 8
src/views/workspacesView.ts View File

@ -1,5 +1,4 @@
import type { Disposable } from 'vscode';
import { env, ProgressLocation, Uri, window } from 'vscode';
import { Disposable, env, ProgressLocation, Uri, window } from 'vscode';
import type { WorkspacesViewConfig } from '../config';
import { Commands } from '../constants';
import type { Container } from '../container';
@ -24,8 +23,8 @@ export class WorkspacesView extends ViewBase<'workspaces', WorkspacesViewNode, W
constructor(container: Container) {
super(container, 'workspaces', 'Workspaces', 'workspaceView');
this._disposable = this.container.workspaces.onDidChangeWorkspaces(
() => void this.ensureRoot().triggerChange(true),
this._disposable = Disposable.from(
this.container.workspaces.onDidResetWorkspaces(() => void this.ensureRoot().triggerChange(true)),
);
this.description = `PREVIEW\u00a0\u00a0☁️`;
}
@ -70,7 +69,6 @@ export class WorkspacesView extends ViewBase<'workspaces', WorkspacesViewNode, W
this.getQualifiedCommand('refresh'),
() => {
this.container.workspaces.resetWorkspaces();
void this.ensureRoot().triggerChange(true);
},
this,
),
@ -103,10 +101,28 @@ export class WorkspacesView extends ViewBase<'workspaces', WorkspacesViewNode, W
this,
),
registerViewCommand(
this.getQualifiedCommand('open'),
this.getQualifiedCommand('createLocal'),
async (node: WorkspaceNode) => {
await this.container.workspaces.saveAsCodeWorkspaceFile(node.workspace.id);
void this.ensureRoot().triggerChange(true);
},
this,
),
registerViewCommand(
this.getQualifiedCommand('openLocal'),
async (node: WorkspaceNode) => {
await this.container.workspaces.openCodeWorkspaceFile(node.workspace.id, {
location: OpenWorkspaceLocation.CurrentWindow,
});
void this.ensureRoot().triggerChange(true);
},
this,
),
registerViewCommand(
this.getQualifiedCommand('openLocalNewWindow'),
async (node: WorkspaceNode) => {
await this.container.workspaces.saveAsCodeWorkspaceFile(node.workspace.id, node.workspace.type, {
open: true,
await this.container.workspaces.openCodeWorkspaceFile(node.workspace.id, {
location: OpenWorkspaceLocation.NewWindow,
});
},
this,

Loading…
Cancel
Save