diff --git a/extensions/git/src/editSessionIdentityProvider.ts b/extensions/git/src/editSessionIdentityProvider.ts index 82b6953cf58..eb753d69865 100644 --- a/extensions/git/src/editSessionIdentityProvider.ts +++ b/extensions/git/src/editSessionIdentityProvider.ts @@ -35,4 +35,39 @@ export class GitEditSessionIdentityProvider implements vscode.EditSessionIdentit sha: repository.HEAD?.commit ?? null, }); } + + provideEditSessionIdentityMatch(identity1: string, identity2: string): vscode.EditSessionIdentityMatch { + try { + const normalizedIdentity1 = normalizeEditSessionIdentity(identity1); + const normalizedIdentity2 = normalizeEditSessionIdentity(identity2); + + if (normalizedIdentity1.remote === normalizedIdentity2.remote && + normalizedIdentity1.ref === normalizedIdentity2.ref && + normalizedIdentity1.sha === normalizedIdentity2.sha) { + // This is a perfect match + return vscode.EditSessionIdentityMatch.Complete; + } else if (normalizedIdentity1.sha !== normalizedIdentity2.sha) { + // Same branch and remote but different SHA + return vscode.EditSessionIdentityMatch.Partial; + } else { + return vscode.EditSessionIdentityMatch.None; + } + } catch (ex) { + return vscode.EditSessionIdentityMatch.Partial; + } + } +} + +function normalizeEditSessionIdentity(identity: string) { + let { remote, ref, sha } = JSON.parse(identity); + + if (typeof remote === 'string' && remote.endsWith('.git')) { + remote = remote.slice(0, remote.length - 4); + } + + return { + remote, + ref, + sha + }; } diff --git a/extensions/git/src/typings/vscode.proposed.contribEditSessions.d.ts b/extensions/git/src/typings/vscode.proposed.contribEditSessions.d.ts index e4d10f273f9..048da2830ba 100644 --- a/extensions/git/src/typings/vscode.proposed.contribEditSessions.d.ts +++ b/extensions/git/src/typings/vscode.proposed.contribEditSessions.d.ts @@ -22,8 +22,23 @@ declare module 'vscode' { * * @param workspaceFolder The workspace folder to provide an edit session identity for. * @param token A cancellation token for the request. - * @returns An string representing the edit session identity for the requested workspace folder. + * @returns A string representing the edit session identity for the requested workspace folder. */ provideEditSessionIdentity(workspaceFolder: WorkspaceFolder, token: CancellationToken): ProviderResult; + + /** + * + * @param identity1 An edit session identity. + * @param identity2 A second edit session identity to compare to @param identity1. + * @param token A cancellation token for the request. + * @returns An {@link EditSessionIdentityMatch} representing the edit session identity match confidence for the provided identities. + */ + provideEditSessionIdentityMatch(identity1: string, identity2: string, token: CancellationToken): ProviderResult; + } + + export enum EditSessionIdentityMatch { + Complete = 100, + Partial = 50, + None = 0 } } diff --git a/src/vs/platform/workspace/common/editSessions.ts b/src/vs/platform/workspace/common/editSessions.ts index 3e58d5ce2d4..88823bc422a 100644 --- a/src/vs/platform/workspace/common/editSessions.ts +++ b/src/vs/platform/workspace/common/editSessions.ts @@ -11,6 +11,7 @@ import { IWorkspaceFolder } from 'vs/platform/workspace/common/workspace'; export interface IEditSessionIdentityProvider { readonly scheme: string; getEditSessionIdentifier(workspaceFolder: IWorkspaceFolder, token: CancellationToken): Promise; + provideEditSessionIdentityMatch(workspaceFolder: IWorkspaceFolder, identity1: string, identity2: string, token: CancellationToken): Promise; } export const IEditSessionIdentityService = createDecorator('editSessionIdentityService'); @@ -20,4 +21,11 @@ export interface IEditSessionIdentityService { registerEditSessionIdentityProvider(provider: IEditSessionIdentityProvider): IDisposable; getEditSessionIdentifier(workspaceFolder: IWorkspaceFolder, cancellationTokenSource: CancellationTokenSource): Promise; + provideEditSessionIdentityMatch(workspaceFolder: IWorkspaceFolder, identity1: string, identity2: string, cancellationTokenSource: CancellationTokenSource): Promise; +} + +export enum EditSessionIdentityMatch { + Complete = 100, + Partial = 50, + None = 0, } diff --git a/src/vs/workbench/api/browser/mainThreadWorkspace.ts b/src/vs/workbench/api/browser/mainThreadWorkspace.ts index 17834fbda35..48931201010 100644 --- a/src/vs/workbench/api/browser/mainThreadWorkspace.ts +++ b/src/vs/workbench/api/browser/mainThreadWorkspace.ts @@ -231,6 +231,9 @@ export class MainThreadWorkspace implements MainThreadWorkspaceShape { scheme: scheme, getEditSessionIdentifier: async (workspaceFolder: WorkspaceFolder, token: CancellationToken) => { return this._proxy.$getEditSessionIdentifier(workspaceFolder.uri, token); + }, + provideEditSessionIdentityMatch: async (workspaceFolder: WorkspaceFolder, identity1: string, identity2: string, token: CancellationToken) => { + return this._proxy.$provideEditSessionIdentityMatch(workspaceFolder.uri, identity1, identity2, token); } }); diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index f781761bb04..fa2c12fc101 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -93,6 +93,7 @@ import { combinedDisposable } from 'vs/base/common/lifecycle'; import { checkProposedApiEnabled, ExtensionIdentifierSet, isProposedApiEnabled } from 'vs/workbench/services/extensions/common/extensions'; import { DebugConfigurationProviderTriggerKind } from 'vs/workbench/contrib/debug/common/debug'; import { IExtHostLocalizationService } from 'vs/workbench/api/common/extHostLocalizationService'; +import { EditSessionIdentityMatch } from 'vs/platform/workspace/common/editSessions'; export interface IExtensionRegistries { mine: ExtensionDescriptionRegistry; @@ -1393,6 +1394,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I TabInputInteractiveWindow: extHostTypes.InteractiveWindowInput, TerminalExitReason: extHostTypes.TerminalExitReason, LogLevel: LogLevel, + EditSessionIdentityMatch: EditSessionIdentityMatch }; }; } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index adb51a10a0f..033193deebd 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -69,6 +69,7 @@ import { ILanguageStatus } from 'vs/workbench/services/languageStatus/common/lan import { CandidatePort } from 'vs/workbench/services/remote/common/remoteExplorerService'; import { ITextQueryBuilderOptions } from 'vs/workbench/services/search/common/queryBuilder'; import * as search from 'vs/workbench/services/search/common/search'; +import { EditSessionIdentityMatch } from 'vs/platform/workspace/common/editSessions'; export interface IWorkspaceData extends IStaticWorkspaceData { folders: { uri: UriComponents; name: string; index: number }[]; @@ -1421,6 +1422,7 @@ export interface ExtHostWorkspaceShape { $handleTextSearchResult(result: search.IRawFileMatch2, requestId: number): void; $onDidGrantWorkspaceTrust(): void; $getEditSessionIdentifier(folder: UriComponents, token: CancellationToken): Promise; + $provideEditSessionIdentityMatch(folder: UriComponents, identity1: string, identity2: string, token: CancellationToken): Promise; } export interface ExtHostFileSystemInfoShape { diff --git a/src/vs/workbench/api/common/extHostWorkspace.ts b/src/vs/workbench/api/common/extHostWorkspace.ts index f0f8ac56dbc..75019bcd205 100644 --- a/src/vs/workbench/api/common/extHostWorkspace.ts +++ b/src/vs/workbench/api/common/extHostWorkspace.ts @@ -21,6 +21,7 @@ import { FileSystemProviderCapabilities } from 'vs/platform/files/common/files'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { ILogService } from 'vs/platform/log/common/log'; import { Severity } from 'vs/platform/notification/common/notification'; +import { EditSessionIdentityMatch } from 'vs/platform/workspace/common/editSessions'; import { Workspace, WorkspaceFolder } from 'vs/platform/workspace/common/workspace'; import { IExtHostFileSystemInfo } from 'vs/workbench/api/common/extHostFileSystemInfo'; import { IExtHostInitDataService } from 'vs/workbench/api/common/extHostInitDataService'; @@ -627,6 +628,31 @@ export class ExtHostWorkspace implements ExtHostWorkspaceShape, IExtHostWorkspac return result; } + + async $provideEditSessionIdentityMatch(workspaceFolder: UriComponents, identity1: string, identity2: string, cancellationToken: CancellationToken): Promise { + this._logService.info('Getting edit session identifier for workspaceFolder', workspaceFolder); + const folder = await this.resolveWorkspaceFolder(URI.revive(workspaceFolder)); + if (!folder) { + this._logService.warn('Unable to resolve workspace folder'); + return undefined; + } + + this._logService.info('Invoking #provideEditSessionIdentity for workspaceFolder', folder); + + const provider = this._editSessionIdentityProviders.get(folder.uri.scheme); + this._logService.info(`Provider for scheme ${folder.uri.scheme} is defined: `, !!provider); + if (!provider) { + return undefined; + } + + const result = await provider.provideEditSessionIdentityMatch?.(identity1, identity2, cancellationToken); + this._logService.info('Provider returned edit session identifier match result: ', result); + if (!result) { + return undefined; + } + + return result; + } } export const IExtHostWorkspace = createDecorator('IExtHostWorkspace'); diff --git a/src/vs/workbench/contrib/editSessions/browser/editSessions.contribution.ts b/src/vs/workbench/contrib/editSessions/browser/editSessions.contribution.ts index af450bcd68e..c9faa088792 100644 --- a/src/vs/workbench/contrib/editSessions/browser/editSessions.contribution.ts +++ b/src/vs/workbench/contrib/editSessions/browser/editSessions.contribution.ts @@ -49,7 +49,7 @@ import { isNative } from 'vs/base/common/platform'; import { WorkspaceFolderCountContext } from 'vs/workbench/common/contextkeys'; import { CancellationTokenSource } from 'vs/base/common/cancellation'; import { equals } from 'vs/base/common/objects'; -import { IEditSessionIdentityService } from 'vs/platform/workspace/common/editSessions'; +import { EditSessionIdentityMatch, IEditSessionIdentityService } from 'vs/platform/workspace/common/editSessions'; import { ThemeIcon } from 'vs/platform/theme/common/themeService'; import { IOutputService } from 'vs/workbench/services/output/common/output'; import * as Constants from 'vs/workbench/contrib/logs/common/logConstants'; @@ -377,7 +377,7 @@ export class EditSessionsContribution extends Disposable implements IWorkbenchCo })); } - async resumeEditSession(ref?: string, silent?: boolean): Promise { + async resumeEditSession(ref?: string, silent?: boolean, force?: boolean): Promise { // Edit sessions are not currently supported in empty workspaces // https://github.com/microsoft/vscode/issues/159220 if (this.contextService.getWorkbenchState() === WorkbenchState.EMPTY) { @@ -409,7 +409,7 @@ export class EditSessionsContribution extends Disposable implements IWorkbenchCo } try { - const { changes, conflictingChanges } = await this.generateChanges(editSession, ref); + const { changes, conflictingChanges } = await this.generateChanges(editSession, ref, force); if (changes.length === 0) { return; } @@ -453,7 +453,7 @@ export class EditSessionsContribution extends Disposable implements IWorkbenchCo } } - private async generateChanges(editSession: EditSession, ref: string) { + private async generateChanges(editSession: EditSession, ref: string, force = false) { const changes: ({ uri: URI; type: ChangeType; contents: string | undefined })[] = []; const conflictingChanges = []; const workspaceFolders = this.contextService.getWorkspace().folders; @@ -467,10 +467,33 @@ export class EditSessionsContribution extends Disposable implements IWorkbenchCo for (const f of workspaceFolders) { const identity = await this.editSessionIdentityService.getEditSessionIdentifier(f, cancellationTokenSource); this.logService.info(`Matching identity ${identity} against edit session folder identity ${folder.canonicalIdentity}...`); + if (equals(identity, folder.canonicalIdentity)) { folderRoot = f; break; } + + if (identity !== undefined) { + const match = await this.editSessionIdentityService.provideEditSessionIdentityMatch(f, identity, folder.canonicalIdentity, cancellationTokenSource); + if (match === EditSessionIdentityMatch.Complete) { + folderRoot = f; + break; + } else if (match === EditSessionIdentityMatch.Partial && + this.configurationService.getValue('workbench.experimental.editSessions.partialMatches.enabled') === true + ) { + if (!force) { + // Surface partially matching edit session + this.notificationService.prompt( + Severity.Info, + localize('editSessionPartialMatch', 'You have a pending edit session for this workspace. Would you like to resume it?'), + [{ label: localize('resume', 'Resume'), run: () => this.resumeEditSession(ref, false, true) }] + ); + } else { + folderRoot = f; + break; + } + } + } } } else { folderRoot = workspaceFolders.find((f) => f.name === folder.name); @@ -875,5 +898,11 @@ Registry.as(ConfigurationExtensions.Configuration).regis 'default': 'onReload', 'markdownDeprecationMessage': localize('autoResumeDeprecated', "This setting is deprecated in favor of {0}.", '`#workbench.editSessions.autoResume#`') }, + 'workbench.experimental.editSessions.partialMatches.enabled': { + 'type': 'boolean', + 'tags': ['experimental', 'usesOnlineServices'], + 'default': true, + 'markdownDescription': localize('editSessionsPartialMatchesEnabled', "Controls whether to surface edit sessions which partially match the current session.") + } } }); diff --git a/src/vs/workbench/services/workspaces/common/editSessionIdentityService.ts b/src/vs/workbench/services/workspaces/common/editSessionIdentityService.ts index bf8d83d43ee..f8dfcd883b1 100644 --- a/src/vs/workbench/services/workspaces/common/editSessionIdentityService.ts +++ b/src/vs/workbench/services/workspaces/common/editSessionIdentityService.ts @@ -7,7 +7,7 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; import { IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { InstantiationType, registerSingleton } from 'vs/platform/instantiation/common/extensions'; import { ILogService } from 'vs/platform/log/common/log'; -import { IEditSessionIdentityProvider, IEditSessionIdentityService } from 'vs/platform/workspace/common/editSessions'; +import { EditSessionIdentityMatch, IEditSessionIdentityProvider, IEditSessionIdentityService } from 'vs/platform/workspace/common/editSessions'; import { IWorkspaceFolder } from 'vs/platform/workspace/common/workspace'; import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; @@ -36,11 +36,20 @@ export class EditSessionIdentityService implements IEditSessionIdentityService { const { scheme } = workspaceFolder.uri; const provider = await this.activateProvider(scheme); - this._logService.info(`EditSessionIdentityProvider for scheme ${scheme} available: ${!!provider}`); + this._logService.trace(`EditSessionIdentityProvider for scheme ${scheme} available: ${!!provider}`); return provider?.getEditSessionIdentifier(workspaceFolder, cancellationTokenSource.token); } + async provideEditSessionIdentityMatch(workspaceFolder: IWorkspaceFolder, identity1: string, identity2: string, cancellationTokenSource: CancellationTokenSource): Promise { + const { scheme } = workspaceFolder.uri; + + const provider = await this.activateProvider(scheme); + this._logService.trace(`EditSessionIdentityProvider for scheme ${scheme} available: ${!!provider}`); + + return provider?.provideEditSessionIdentityMatch?.(workspaceFolder, identity1, identity2, cancellationTokenSource.token); + } + private async activateProvider(scheme: string) { const transformedScheme = scheme === 'vscode-remote' ? 'file' : scheme; diff --git a/src/vscode-dts/vscode.proposed.editSessionIdentityProvider.d.ts b/src/vscode-dts/vscode.proposed.editSessionIdentityProvider.d.ts index e4d10f273f9..048da2830ba 100644 --- a/src/vscode-dts/vscode.proposed.editSessionIdentityProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.editSessionIdentityProvider.d.ts @@ -22,8 +22,23 @@ declare module 'vscode' { * * @param workspaceFolder The workspace folder to provide an edit session identity for. * @param token A cancellation token for the request. - * @returns An string representing the edit session identity for the requested workspace folder. + * @returns A string representing the edit session identity for the requested workspace folder. */ provideEditSessionIdentity(workspaceFolder: WorkspaceFolder, token: CancellationToken): ProviderResult; + + /** + * + * @param identity1 An edit session identity. + * @param identity2 A second edit session identity to compare to @param identity1. + * @param token A cancellation token for the request. + * @returns An {@link EditSessionIdentityMatch} representing the edit session identity match confidence for the provided identities. + */ + provideEditSessionIdentityMatch(identity1: string, identity2: string, token: CancellationToken): ProviderResult; + } + + export enum EditSessionIdentityMatch { + Complete = 100, + Partial = 50, + None = 0 } }