Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Only show warning if edit session contents differ from local contents #160464

Merged
merged 2 commits into from Sep 26, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
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