Skip to content

Commit 1f5a547

Browse files
committed
implement backup on shutdown via working copies (#84672)
1 parent 1420967 commit 1f5a547

24 files changed

+627
-498
lines changed

build/lib/i18n.resources.json

+4
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
"name": "vs/workbench/api/common",
3131
"project": "vscode-workbench"
3232
},
33+
{
34+
"name": "vs/workbench/contrib/backup",
35+
"project": "vscode-workbench"
36+
},
3337
{
3438
"name": "vs/workbench/contrib/bulkEdit",
3539
"project": "vscode-workbench"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { Registry } from 'vs/platform/registry/common/platform';
7+
import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from 'vs/workbench/common/contributions';
8+
import { LifecyclePhase } from 'vs/platform/lifecycle/common/lifecycle';
9+
import { BackupOnShutdown } from 'vs/workbench/contrib/backup/browser/backupOnShutdown';
10+
11+
// Register Backup On Shutdown
12+
Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench).registerWorkbenchContribution(BackupOnShutdown, LifecyclePhase.Starting);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { Disposable } from 'vs/base/common/lifecycle';
7+
import { IWorkbenchContribution } from 'vs/workbench/common/contributions';
8+
import { IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService';
9+
import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService';
10+
import { ILifecycleService } from 'vs/platform/lifecycle/common/lifecycle';
11+
12+
export class BackupOnShutdown extends Disposable implements IWorkbenchContribution {
13+
14+
constructor(
15+
@IFilesConfigurationService private readonly filesConfigurationService: IFilesConfigurationService,
16+
@IWorkingCopyService private readonly workingCopyService: IWorkingCopyService,
17+
@ILifecycleService private readonly lifecycleService: ILifecycleService,
18+
) {
19+
super();
20+
21+
this.registerListeners();
22+
}
23+
24+
private registerListeners() {
25+
26+
// Lifecycle
27+
this.lifecycleService.onBeforeShutdown(event => event.veto(this.onBeforeShutdown()));
28+
}
29+
30+
private onBeforeShutdown(): boolean {
31+
32+
// Web: we cannot perform long running in the shutdown phase
33+
// As such we need to check sync if there are any dirty working
34+
// copies that have not been backed up yet and then prevent the
35+
// shutdown if that is the case.
36+
37+
const dirtyWorkingCopies = this.workingCopyService.workingCopies.filter(workingCopy => workingCopy.isDirty());
38+
if (!dirtyWorkingCopies.length) {
39+
return false; // no dirty: no veto
40+
}
41+
42+
if (!this.filesConfigurationService.isHotExitEnabled) {
43+
return true; // dirty without backup: veto
44+
}
45+
46+
for (const dirtyWorkingCopy of dirtyWorkingCopies) {
47+
if (!dirtyWorkingCopy.hasBackup()) {
48+
console.warn('Unload prevented: pending backups');
49+
return true; // dirty without backup: veto
50+
}
51+
}
52+
53+
return false; // dirty with backups: no veto
54+
}
55+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { Registry } from 'vs/platform/registry/common/platform';
7+
import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from 'vs/workbench/common/contributions';
8+
import { LifecyclePhase } from 'vs/platform/lifecycle/common/lifecycle';
9+
import { BackupOnShutdown } from 'vs/workbench/contrib/backup/electron-browser/backupOnShutdown';
10+
11+
// Register Backup On Shutdown
12+
Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench).registerWorkbenchContribution(BackupOnShutdown, LifecyclePhase.Starting);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { localize } from 'vs/nls';
7+
import { IBackupFileService } from 'vs/workbench/services/backup/common/backup';
8+
import { Disposable } from 'vs/base/common/lifecycle';
9+
import { IWorkbenchContribution } from 'vs/workbench/common/contributions';
10+
import { IFilesConfigurationService, AutoSaveMode } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService';
11+
import { IWorkingCopyService, IWorkingCopy, WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopyService';
12+
import { ILifecycleService, LifecyclePhase, ShutdownReason } from 'vs/platform/lifecycle/common/lifecycle';
13+
import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService';
14+
import { ConfirmResult, IFileDialogService } from 'vs/platform/dialogs/common/dialogs';
15+
import { INotificationService } from 'vs/platform/notification/common/notification';
16+
import { WorkbenchState, IWorkspaceContextService } from 'vs/platform/workspace/common/workspace';
17+
import { isMacintosh } from 'vs/base/common/platform';
18+
import { HotExitConfiguration } from 'vs/platform/files/common/files';
19+
import { IElectronService } from 'vs/platform/electron/node/electron';
20+
import type { ISaveOptions, IRevertOptions } from 'vs/workbench/common/editor';
21+
22+
export class BackupOnShutdown extends Disposable implements IWorkbenchContribution {
23+
24+
constructor(
25+
@IBackupFileService private readonly backupFileService: IBackupFileService,
26+
@IFilesConfigurationService private readonly filesConfigurationService: IFilesConfigurationService,
27+
@IWorkingCopyService private readonly workingCopyService: IWorkingCopyService,
28+
@ILifecycleService private readonly lifecycleService: ILifecycleService,
29+
@IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService,
30+
@IFileDialogService private readonly fileDialogService: IFileDialogService,
31+
@INotificationService private readonly notificationService: INotificationService,
32+
@IWorkspaceContextService private readonly contextService: IWorkspaceContextService,
33+
@IElectronService private readonly electronService: IElectronService
34+
) {
35+
super();
36+
37+
this.registerListeners();
38+
}
39+
40+
private registerListeners() {
41+
42+
// Lifecycle
43+
this.lifecycleService.onBeforeShutdown(event => event.veto(this.onBeforeShutdown(event.reason)));
44+
}
45+
46+
private onBeforeShutdown(reason: ShutdownReason): boolean | Promise<boolean> {
47+
48+
// Dirty working copies need treatment on shutdown
49+
const dirtyWorkingCopies = this.workingCopyService.workingCopies.filter(workingCopy => workingCopy.isDirty());
50+
if (dirtyWorkingCopies.length) {
51+
52+
// If auto save is enabled, save all working copies and then check again for dirty copies
53+
// We DO NOT run any save participant if we are in the shutdown phase for performance reasons
54+
if (this.filesConfigurationService.getAutoSaveMode() !== AutoSaveMode.OFF) {
55+
return this.doSaveAll(dirtyWorkingCopies, false /* not untitled */, { skipSaveParticipants: true }).then(() => {
56+
57+
// If we still have dirty working copies, we either have untitled ones or working copies that cannot be saved
58+
const remainingDirtyWorkingCopies = this.workingCopyService.workingCopies.filter(workingCopy => workingCopy.isDirty());
59+
if (remainingDirtyWorkingCopies.length) {
60+
return this.handleDirtyBeforeShutdown(remainingDirtyWorkingCopies, reason);
61+
}
62+
63+
return this.noVeto({ cleanUpBackups: false }); // no veto and no backup cleanup (since there are no dirty working copies)
64+
});
65+
}
66+
67+
// Auto save is not enabled
68+
return this.handleDirtyBeforeShutdown(dirtyWorkingCopies, reason);
69+
}
70+
71+
// No dirty working copies: no veto
72+
return this.noVeto({ cleanUpBackups: true });
73+
}
74+
75+
private handleDirtyBeforeShutdown(workingCopies: IWorkingCopy[], reason: ShutdownReason): boolean | Promise<boolean> {
76+
77+
// If hot exit is enabled, backup dirty working copies and allow to exit without confirmation
78+
if (this.filesConfigurationService.isHotExitEnabled) {
79+
return this.backupBeforeShutdown(workingCopies, reason).then(didBackup => {
80+
if (didBackup) {
81+
return this.noVeto({ cleanUpBackups: false }); // no veto and no backup cleanup (since backup was successful)
82+
}
83+
84+
// since a backup did not happen, we have to confirm for the dirty working copies now
85+
return this.confirmBeforeShutdown();
86+
}, error => {
87+
this.notificationService.error(localize('backupOnShutdown.failSave', "Working copies that are dirty could not be written to the backup location (Error: {0}). Try saving your editors first and then exit.", error.message));
88+
89+
return true; // veto, the backups failed
90+
});
91+
}
92+
93+
// Otherwise just confirm from the user what to do with the dirty working copies
94+
return this.confirmBeforeShutdown();
95+
}
96+
97+
private async backupBeforeShutdown(workingCopies: IWorkingCopy[], reason: ShutdownReason): Promise<boolean> {
98+
99+
// When quit is requested skip the confirm callback and attempt to backup all workspaces.
100+
// When quit is not requested the confirm callback should be shown when the window being
101+
// closed is the only VS Code window open, except for on Mac where hot exit is only
102+
// ever activated when quit is requested.
103+
104+
let doBackup: boolean | undefined;
105+
switch (reason) {
106+
case ShutdownReason.CLOSE:
107+
if (this.contextService.getWorkbenchState() !== WorkbenchState.EMPTY && this.filesConfigurationService.hotExitConfiguration === HotExitConfiguration.ON_EXIT_AND_WINDOW_CLOSE) {
108+
doBackup = true; // backup if a folder is open and onExitAndWindowClose is configured
109+
} else if (await this.electronService.getWindowCount() > 1 || isMacintosh) {
110+
doBackup = false; // do not backup if a window is closed that does not cause quitting of the application
111+
} else {
112+
doBackup = true; // backup if last window is closed on win/linux where the application quits right after
113+
}
114+
break;
115+
116+
case ShutdownReason.QUIT:
117+
doBackup = true; // backup because next start we restore all backups
118+
break;
119+
120+
case ShutdownReason.RELOAD:
121+
doBackup = true; // backup because after window reload, backups restore
122+
break;
123+
124+
case ShutdownReason.LOAD:
125+
if (this.contextService.getWorkbenchState() !== WorkbenchState.EMPTY && this.filesConfigurationService.hotExitConfiguration === HotExitConfiguration.ON_EXIT_AND_WINDOW_CLOSE) {
126+
doBackup = true; // backup if a folder is open and onExitAndWindowClose is configured
127+
} else {
128+
doBackup = false; // do not backup because we are switching contexts
129+
}
130+
break;
131+
}
132+
133+
if (!doBackup) {
134+
return false;
135+
}
136+
137+
// Backup all working copies
138+
await Promise.all(workingCopies.map(workingCopy => workingCopy.backup()));
139+
140+
return true;
141+
}
142+
143+
private async confirmBeforeShutdown(): Promise<boolean> {
144+
145+
// Show confirm dialog for all dirty working copies
146+
const dirtyWorkingCopies = this.workingCopyService.workingCopies.filter(workingCopy => workingCopy.isDirty());
147+
const confirm = await this.fileDialogService.showSaveConfirm(dirtyWorkingCopies.map(w => w.resource));
148+
149+
// Save
150+
if (confirm === ConfirmResult.SAVE) {
151+
await this.doSaveAll(dirtyWorkingCopies, true /* includeUntitled */, { skipSaveParticipants: true });
152+
153+
if (this.workingCopyService.hasDirty) {
154+
return true; // veto if any save failed
155+
}
156+
157+
return this.noVeto({ cleanUpBackups: true });
158+
}
159+
160+
// Don't Save
161+
else if (confirm === ConfirmResult.DONT_SAVE) {
162+
163+
// Make sure to revert working copies so that they do not restore
164+
// see https://github.com/Microsoft/vscode/issues/29572
165+
await this.doRevertAll(dirtyWorkingCopies, { soft: true } /* soft revert is good enough on shutdown */);
166+
167+
return this.noVeto({ cleanUpBackups: true });
168+
}
169+
170+
// Cancel
171+
else if (confirm === ConfirmResult.CANCEL) {
172+
return true; // veto
173+
}
174+
175+
return false;
176+
}
177+
178+
private doSaveAll(workingCopies: IWorkingCopy[], includeUntitled: boolean, options: ISaveOptions): Promise<boolean[]> {
179+
return Promise.all(workingCopies.map(async workingCopy => {
180+
if (workingCopy.isDirty() && (includeUntitled || !(workingCopy.capabilities & WorkingCopyCapabilities.Untitled))) {
181+
return workingCopy.save(options);
182+
}
183+
184+
return false;
185+
}));
186+
}
187+
188+
private doRevertAll(workingCopies: IWorkingCopy[], options: IRevertOptions): Promise<boolean[]> {
189+
return Promise.all(workingCopies.map(workingCopy => workingCopy.revert(options)));
190+
}
191+
192+
private noVeto(options: { cleanUpBackups: boolean }): boolean | Promise<boolean> {
193+
if (!options.cleanUpBackups) {
194+
return false;
195+
}
196+
197+
if (this.lifecycleService.phase < LifecyclePhase.Restored) {
198+
return false; // if editors have not restored, we are not up to speed with backups and thus should not clean them
199+
}
200+
201+
if (this.environmentService.isExtensionDevelopment) {
202+
return false; // extension development does not track any backups
203+
}
204+
205+
return this.backupFileService.discardAllWorkspaceBackups().then(() => false, () => false);
206+
}
207+
}

0 commit comments

Comments
 (0)