Skip to content

Commit

Permalink
* Don't present modal dialog if server edits are identical to local e…
Browse files Browse the repository at this point in the history
…dits

* Use custom modal to avoid stealing window focus when resuming edit sessions
  • Loading branch information
joyceerhl committed Sep 9, 2022
1 parent 4987750 commit 96b7baf
Show file tree
Hide file tree
Showing 2 changed files with 108 additions and 57 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,16 @@ import { ISCMRepository, ISCMService } from 'vs/workbench/contrib/scm/common/scm
import { IFileService } from 'vs/platform/files/common/files';
import { IWorkspaceContextService, IWorkspaceFolder, WorkbenchState } from 'vs/platform/workspace/common/workspace';
import { URI } from 'vs/base/common/uri';
import { joinPath, relativePath } from 'vs/base/common/resources';
import { basename, joinPath, relativePath } from 'vs/base/common/resources';
import { encodeBase64 } from 'vs/base/common/buffer';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { IProgressService, ProgressLocation } from 'vs/platform/progress/common/progress';
import { EditSessionsWorkbenchService } from 'vs/workbench/contrib/editSessions/browser/editSessionsStorageService';
import { registerSingleton } from 'vs/platform/instantiation/common/extensions';
import { UserDataSyncErrorCode, UserDataSyncStoreError } from 'vs/platform/userDataSync/common/userDataSync';
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
import { INotificationService } from 'vs/platform/notification/common/notification';
import { IDialogService, IFileDialogService } from 'vs/platform/dialogs/common/dialogs';
import { INotificationService, Severity } from 'vs/platform/notification/common/notification';
import { getFileNamesMessage, IDialogService, IFileDialogService } from 'vs/platform/dialogs/common/dialogs';
import { IProductService } from 'vs/platform/product/common/productService';
import { IOpenerService } from 'vs/platform/opener/common/opener';
import { IEnvironmentService } from 'vs/platform/environment/common/environment';
Expand Down Expand Up @@ -53,6 +53,7 @@ import { IEditSessionIdentityService } from 'vs/platform/workspace/common/editSe
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';
import { sha1Hex } from 'vs/base/browser/hash';

registerSingleton(IEditSessionsLogService, EditSessionsLogService, false);
registerSingleton(IEditSessionsStorageService, EditSessionsWorkbenchService, false);
Expand Down Expand Up @@ -367,57 +368,27 @@ export class EditSessionsContribution extends Disposable implements IWorkbenchCo
}

try {
const changes: ({ uri: URI; type: ChangeType; contents: string | undefined })[] = [];
let hasLocalUncommittedChanges = false;
const workspaceFolders = this.contextService.getWorkspace().folders;

for (const folder of editSession.folders) {
const cancellationTokenSource = new CancellationTokenSource();
let folderRoot: IWorkspaceFolder | undefined;

if (folder.canonicalIdentity) {
// Look for an edit session identifier that we can use
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;
}
}
} else {
folderRoot = workspaceFolders.find((f) => f.name === folder.name);
}

if (!folderRoot) {
this.logService.info(`Skipping applying ${folder.workingChanges.length} changes from edit session with ref ${ref} as no matching workspace folder was found.`);
return;
}

for (const repository of this.scmService.repositories) {
if (repository.provider.rootUri !== undefined &&
this.contextService.getWorkspaceFolder(repository.provider.rootUri)?.name === folder.name &&
this.getChangedResources(repository).length > 0
) {
hasLocalUncommittedChanges = true;
break;
}
}

for (const { relativeFilePath, contents, type } of folder.workingChanges) {
const uri = joinPath(folderRoot.uri, relativeFilePath);
changes.push({ uri: uri, type: type, contents: contents });
}
}
const { changes, conflictingChanges } = await this.generateChanges(editSession, ref);

// TODO@joyceerhl Provide the option to diff files which would be overwritten by edit session contents
if (conflictingChanges.length > 0) {
const yes = localize('resume edit session yes', 'Yes');
const cancel = localize('resume edit session cancel', 'Cancel');
// Allow to show edit sessions

const result = await this.dialogService.show(
Severity.Warning,
changes.length > 1 ?
localize('resume edit session warning many', 'Resuming your edit session will overwrite the following {0} files. Do you want to proceed?', changes.length) :
localize('resume edit session warning 1', 'Resuming your edit session will overwrite {0}. Do you want to proceed?', basename(changes[0].uri)),
[cancel, yes],
{
custom: true,
detail: changes.length > 1 ? getFileNamesMessage(conflictingChanges.map((c) => c.uri)) : undefined,
cancelId: 0
});

if (hasLocalUncommittedChanges) {
// TODO@joyceerhl Provide the option to diff files which would be overwritten by edit session contents
const result = await this.dialogService.confirm({
message: localize('resume edit session warning', 'Resuming your edit session may overwrite your existing uncommitted changes. Do you want to proceed?'),
type: 'warning',
title: EDIT_SESSION_SYNC_CATEGORY.value
});
if (!result.confirmed) {
if (result.choice === 0) {
return;
}
}
Expand All @@ -439,6 +410,77 @@ export class EditSessionsContribution extends Disposable implements IWorkbenchCo
}
}

private async generateChanges(editSession: EditSession, ref: string) {
const changes: ({ uri: URI; type: ChangeType; contents: string | undefined })[] = [];
const conflictingChanges = [];
const workspaceFolders = this.contextService.getWorkspace().folders;

for (const folder of editSession.folders) {
const cancellationTokenSource = new CancellationTokenSource();
let folderRoot: IWorkspaceFolder | undefined;

if (folder.canonicalIdentity) {
// Look for an edit session identifier that we can use
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;
}
}
} else {
folderRoot = workspaceFolders.find((f) => f.name === folder.name);
}

if (!folderRoot) {
this.logService.info(`Skipping applying ${folder.workingChanges.length} changes from edit session with ref ${ref} as no matching workspace folder was found.`);
return { changes: [], conflictingChanges: [] };
}

const localChanges = new Set<string>();
for (const repository of this.scmService.repositories) {
if (repository.provider.rootUri !== undefined &&
this.contextService.getWorkspaceFolder(repository.provider.rootUri)?.name === folder.name
) {
const repositoryChanges = this.getChangedResources(repository);
repositoryChanges.forEach((change) => localChanges.add(change.toString()));
}
}

for (const change of folder.workingChanges) {
const uri = joinPath(folderRoot.uri, change.relativeFilePath);

changes.push({ uri, type: change.type, contents: change.contents });
if (await this.willChangeLocalContents(localChanges, uri, change)) {
conflictingChanges.push({ uri, type: change.type, contents: change.contents });
}
}
}

return { changes, conflictingChanges };
}

private async willChangeLocalContents(localChanges: Set<string>, uriWithIncomingChanges: URI, incomingChange: Change) {
if (!localChanges.has(uriWithIncomingChanges.toString())) {
return false;
}

const { contents, type } = incomingChange;

switch (type) {
case (ChangeType.Addition): {
const [originalContents, incomingContents] = await Promise.all([sha1Hex(contents), sha1Hex(encodeBase64((await this.fileService.readFile(uriWithIncomingChanges)).value))]);
return originalContents !== incomingContents;
}
case (ChangeType.Deletion): {
return await this.fileService.exists(uriWithIncomingChanges);
}
default:
throw new Error('Unhandled change type.');
}
}

async storeEditSession(fromStoreCommand: boolean): Promise<string | undefined> {
const folders: Folder[] = [];
let hasEdits = false;
Expand Down Expand Up @@ -530,17 +572,15 @@ export class EditSessionsContribution extends Disposable implements IWorkbenchCo
}

private getChangedResources(repository: ISCMRepository) {
const trackedUris = repository.provider.groups.elements.reduce((resources, resourceGroups) => {
return repository.provider.groups.elements.reduce((resources, resourceGroups) => {
resourceGroups.elements.forEach((resource) => resources.add(resource.sourceUri));
return resources;
}, new Set<URI>()); // A URI might appear in more than one resource group

return [...trackedUris];
}

private hasEditSession() {
for (const repository of this.scmService.repositories) {
if (this.getChangedResources(repository).length > 0) {
if (this.getChangedResources(repository).size > 0) {
return true;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { Event } from 'vs/base/common/event';
import { IViewDescriptorService } from 'vs/workbench/common/views';
import { ITextModelService } from 'vs/editor/common/services/resolverService';
import { ILifecycleService } from 'vs/workbench/services/lifecycle/common/lifecycle';
import { IDialogService } from 'vs/platform/dialogs/common/dialogs';

const folderName = 'test-folder';
const folderUri = URI.file(`/${folderName}`);
Expand Down Expand Up @@ -69,6 +70,11 @@ suite('Edit session sync', () => {
instantiationService.stub(IProgressService, ProgressService);
instantiationService.stub(ISCMService, SCMService);
instantiationService.stub(IEnvironmentService, TestEnvironmentService);
instantiationService.stub(IDialogService, new class extends mock<IDialogService>() {
override async show() {
return { choice: 1 };
}
});
instantiationService.stub(IConfigurationService, new TestConfigurationService({ workbench: { experimental: { editSessions: { enabled: true } } } }));
instantiationService.stub(IWorkspaceContextService, new class extends mock<IWorkspaceContextService>() {
override getWorkspace() {
Expand Down Expand Up @@ -133,6 +139,10 @@ suite('Edit session sync', () => {
const readStub = sandbox.stub().returns({ editSession, ref: '0' });
instantiationService.stub(IEditSessionsStorageService, 'read', readStub);

// Ensure that user does not get prompted here
const dialogServiceShowStub = sandbox.stub();
instantiationService.stub(IDialogService, 'show', dialogServiceShowStub);

// Create root folder
await fileService.createFolder(folderUri);

Expand All @@ -141,6 +151,7 @@ suite('Edit session sync', () => {

// Verify edit session was correctly applied
assert.equal((await fileService.readFile(fileUri)).value.toString(), fileContents);
assert.equal(dialogServiceShowStub.called, false);
});

test('Edit session not stored if there are no edits', async function () {
Expand Down

0 comments on commit 96b7baf

Please sign in to comment.