historyMainService.ts 14.2 KB
Newer Older
1 2 3 4 5 6 7
/*---------------------------------------------------------------------------------------------
 *  Copyright (c) Microsoft Corporation. All rights reserved.
 *  Licensed under the MIT License. See License.txt in the project root for license information.
 *--------------------------------------------------------------------------------------------*/

import * as nls from 'vs/nls';
import * as arrays from 'vs/base/common/arrays';
B
Benjamin Pasero 已提交
8
import { IStateService } from 'vs/platform/state/common/state';
9 10
import { app } from 'electron';
import { ILogService } from 'vs/platform/log/common/log';
I
isidor 已提交
11
import { getBaseLabel, getPathLabel } from 'vs/base/common/labels';
12
import { IPath } from 'vs/platform/windows/common/windows';
M
Matt Bierner 已提交
13
import { Event as CommonEvent, Emitter } from 'vs/base/common/event';
B
Benjamin Pasero 已提交
14
import { isWindows, isMacintosh, isLinux } from 'vs/base/common/platform';
15
import { IWorkspaceIdentifier, IWorkspacesMainService, IWorkspaceSavedEvent, ISingleFolderWorkspaceIdentifier, isSingleFolderWorkspaceIdentifier, isWorkspaceIdentifier } from 'vs/platform/workspaces/common/workspaces';
B
Benjamin Pasero 已提交
16 17
import { IHistoryMainService, IRecentlyOpened } from 'vs/platform/history/common/history';
import { isEqual } from 'vs/base/common/paths';
18
import { RunOnceScheduler } from 'vs/base/common/async';
19
import { getComparisonKey, isEqual as areResourcesEqual, dirname } from 'vs/base/common/resources';
20
import { URI, UriComponents } from 'vs/base/common/uri';
21
import { Schemas } from 'vs/base/common/network';
I
isidor 已提交
22 23
import { IEnvironmentService } from 'vs/platform/environment/common/environment';
import { getSimpleWorkspaceLabel } from 'vs/platform/label/common/label';
24 25

interface ISerializedRecentlyOpened {
26
	workspaces2: Array<IWorkspaceIdentifier | string>; // IWorkspaceIdentifier or URI.toString()
27
	files2: string[]; // files as URI.toString()
28
}
29

30
interface ILegacySerializedRecentlyOpened {
31
	workspaces: Array<IWorkspaceIdentifier | string | UriComponents>; // legacy (UriComponents was also supported for a few insider builds)
32
	files: string[]; // files as paths
33
}
34

35 36
export class HistoryMainService implements IHistoryMainService {

37
	private static readonly MAX_TOTAL_RECENT_ENTRIES = 100;
38 39
	private static readonly MAX_MACOS_DOCK_RECENT_FOLDERS = 10;
	private static readonly MAX_MACOS_DOCK_RECENT_FILES = 5;
40

41
	private static readonly recentlyOpenedStorageKey = 'openedPathsList';
42 43 44

	_serviceBrand: any;

B
Benjamin Pasero 已提交
45 46
	private _onRecentlyOpenedChange = new Emitter<void>();
	onRecentlyOpenedChange: CommonEvent<void> = this._onRecentlyOpenedChange.event;
47

48 49
	private macOSRecentDocumentsUpdater: RunOnceScheduler;

50
	constructor(
B
Benjamin Pasero 已提交
51
		@IStateService private stateService: IStateService,
B
Benjamin Pasero 已提交
52
		@ILogService private logService: ILogService,
B
Benjamin Pasero 已提交
53
		@IWorkspacesMainService private workspacesMainService: IWorkspacesMainService,
I
isidor 已提交
54
		@IEnvironmentService private environmentService: IEnvironmentService
55
	) {
56 57
		this.macOSRecentDocumentsUpdater = new RunOnceScheduler(() => this.updateMacOSRecentDocuments(), 800);

58 59 60 61
		this.registerListeners();
	}

	private registerListeners(): void {
B
Benjamin Pasero 已提交
62
		this.workspacesMainService.onWorkspaceSaved(e => this.onWorkspaceSaved(e));
63 64 65 66
	}

	private onWorkspaceSaved(e: IWorkspaceSavedEvent): void {

67 68
		// Make sure to add newly saved workspaces to the list of recent workspaces
		this.addRecentlyOpened([e.workspace], []);
69 70
	}

71
	addRecentlyOpened(workspaces: Array<IWorkspaceIdentifier | ISingleFolderWorkspaceIdentifier>, files: URI[]): void {
72 73
		if ((workspaces && workspaces.length > 0) || (files && files.length > 0)) {
			const mru = this.getRecentlyOpened();
74

75
			// Workspaces
B
Benjamin Pasero 已提交
76 77
			if (Array.isArray(workspaces)) {
				workspaces.forEach(workspace => {
78
					const isUntitledWorkspace = !isSingleFolderWorkspaceIdentifier(workspace) && this.workspacesMainService.isUntitledWorkspace(workspace);
B
Benjamin Pasero 已提交
79 80 81
					if (isUntitledWorkspace) {
						return; // only store saved workspaces
					}
82

B
Benjamin Pasero 已提交
83 84
					mru.workspaces.unshift(workspace);
					mru.workspaces = arrays.distinct(mru.workspaces, workspace => this.distinctFn(workspace));
85

B
Benjamin Pasero 已提交
86 87 88 89
					// We do not add to recent documents here because on Windows we do this from a custom
					// JumpList and on macOS we fill the recent documents in one go from all our data later.
				});
			}
90

91
			// Files
B
Benjamin Pasero 已提交
92
			if (Array.isArray(files)) {
93 94
				files.forEach((fileUri) => {
					mru.files.unshift(fileUri);
B
Benjamin Pasero 已提交
95 96 97
					mru.files = arrays.distinct(mru.files, file => this.distinctFn(file));

					// Add to recent documents (Windows only, macOS later)
98 99
					if (isWindows && fileUri.scheme === Schemas.file) {
						app.addRecentDocument(fileUri.fsPath);
B
Benjamin Pasero 已提交
100 101 102
					}
				});
			}
103 104

			// Make sure its bounded
105
			mru.workspaces = mru.workspaces.slice(0, HistoryMainService.MAX_TOTAL_RECENT_ENTRIES);
106 107
			mru.files = mru.files.slice(0, HistoryMainService.MAX_TOTAL_RECENT_ENTRIES);

108
			this.saveRecentlyOpened(mru);
109
			this._onRecentlyOpenedChange.fire();
110 111 112 113 114

			// Schedule update to recent documents on macOS dock
			if (isMacintosh) {
				this.macOSRecentDocumentsUpdater.schedule();
			}
115
		}
116 117
	}

118
	removeFromRecentlyOpened(pathsToRemove: Array<IWorkspaceIdentifier | ISingleFolderWorkspaceIdentifier | URI | string>): void {
B
Benjamin Pasero 已提交
119
		const mru = this.getRecentlyOpened();
120 121
		let update = false;

122
		pathsToRemove.forEach(pathToRemove => {
123 124

			// Remove workspace
125 126 127 128
			let index = arrays.firstIndex(mru.workspaces, workspace => {
				if (isWorkspaceIdentifier(pathToRemove)) {
					return isWorkspaceIdentifier(workspace) && isEqual(pathToRemove.configPath, workspace.configPath, !isLinux /* ignorecase */);
				}
129
				if (isSingleFolderWorkspaceIdentifier(pathToRemove)) {
130
					return isSingleFolderWorkspaceIdentifier(workspace) && areResourcesEqual(pathToRemove, workspace);
131
				}
S
Sandeep Somavarapu 已提交
132
				if (typeof pathToRemove === 'string') {
133
					if (isSingleFolderWorkspaceIdentifier(workspace)) {
134
						return workspace.scheme === Schemas.file && isEqual(pathToRemove, workspace.fsPath, !isLinux /* ignorecase */);
135 136 137 138 139 140 141
					}
					if (isWorkspaceIdentifier(workspace)) {
						return isEqual(pathToRemove, workspace.configPath, !isLinux /* ignorecase */);
					}
				}
				return false;
			});
142 143 144
			if (index >= 0) {
				mru.workspaces.splice(index, 1);
				update = true;
145 146
			}

147
			// Remove file
148 149 150 151 152 153 154 155 156
			index = arrays.firstIndex(mru.files, file => {
				if (pathToRemove instanceof URI) {
					return areResourcesEqual(file, pathToRemove);
				} else if (typeof pathToRemove === 'string') {
					return isEqual(file.fsPath, pathToRemove, !isLinux /* ignorecase */);
				}
				return false;
			});

157 158 159
			if (index >= 0) {
				mru.files.splice(index, 1);
				update = true;
160
			}
161
		});
162 163

		if (update) {
164
			this.saveRecentlyOpened(mru);
B
Benjamin Pasero 已提交
165
			this._onRecentlyOpenedChange.fire();
166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186

			// Schedule update to recent documents on macOS dock
			if (isMacintosh) {
				this.macOSRecentDocumentsUpdater.schedule();
			}
		}
	}

	private updateMacOSRecentDocuments(): void {
		if (!isMacintosh) {
			return;
		}

		// macOS recent documents in the dock are behaving strangely. the entries seem to get
		// out of sync quickly over time. the attempted fix is to always set the list fresh
		// from our MRU history data. So we clear the documents first and then set the documents
		// again.
		app.clearRecentDocuments();

		const mru = this.getRecentlyOpened();

187 188 189
		// Fill in workspaces
		let entries = 0;
		for (let i = 0; i < mru.workspaces.length && entries < HistoryMainService.MAX_MACOS_DOCK_RECENT_FOLDERS; i++) {
190 191 192 193
			const workspace = mru.workspaces[i];
			if (isSingleFolderWorkspaceIdentifier(workspace)) {
				if (workspace.scheme === Schemas.file) {
					app.addRecentDocument(workspace.fsPath);
194
					entries++;
195 196 197
				}
			} else {
				app.addRecentDocument(workspace.configPath);
198
				entries++;
199
			}
200 201
		}

202 203 204
		// Fill in files
		entries = 0;
		for (let i = 0; i < mru.files.length && entries < HistoryMainService.MAX_MACOS_DOCK_RECENT_FILES; i++) {
205
			const file = mru.files[i];
206 207
			if (file.scheme === Schemas.file) {
				app.addRecentDocument(file.fsPath);
208
				entries++;
209
			}
210 211 212
		}
	}

B
Benjamin Pasero 已提交
213
	clearRecentlyOpened(): void {
214
		this.saveRecentlyOpened({ workspaces: [], files: [] });
215 216 217
		app.clearRecentDocuments();

		// Event
B
Benjamin Pasero 已提交
218
		this._onRecentlyOpenedChange.fire();
219 220
	}

221
	getRecentlyOpened(currentWorkspace?: IWorkspaceIdentifier | ISingleFolderWorkspaceIdentifier, currentFiles?: IPath[]): IRecentlyOpened {
222
		let workspaces: Array<IWorkspaceIdentifier | ISingleFolderWorkspaceIdentifier>;
223
		let files: URI[];
224 225

		// Get from storage
226
		const storedRecents = this.getRecentlyOpenedFromStorage();
227
		if (storedRecents) {
228
			workspaces = storedRecents.workspaces || [];
229 230
			files = storedRecents.files || [];
		} else {
231
			workspaces = [];
232 233 234
			files = [];
		}

235 236 237 238 239
		// Add current workspace to beginning if set
		if (currentWorkspace) {
			workspaces.unshift(currentWorkspace);
		}

240
		// Add currently files to open to the beginning if any
241
		if (currentFiles) {
M
Matt Bierner 已提交
242
			files.unshift(...arrays.coalesce(currentFiles.map(f => f.fileUri)));
243 244 245
		}

		// Clear those dupes
246
		workspaces = arrays.distinct(workspaces, workspace => this.distinctFn(workspace));
B
Benjamin Pasero 已提交
247
		files = arrays.distinct(files, file => this.distinctFn(file));
248

249
		// Hide untitled workspaces
250
		workspaces = workspaces.filter(workspace => isSingleFolderWorkspaceIdentifier(workspace) || !this.workspacesMainService.isUntitledWorkspace(workspace));
251

252
		return { workspaces, files };
253 254
	}

255 256
	private distinctFn(workspaceOrFile: IWorkspaceIdentifier | ISingleFolderWorkspaceIdentifier | URI): string {
		if (workspaceOrFile instanceof URI) {
257 258
			return getComparisonKey(workspaceOrFile);
		}
259

260
		return workspaceOrFile.id;
261 262
	}

263
	private getRecentlyOpenedFromStorage(): IRecentlyOpened {
264
		const storedRecents = this.stateService.getItem<ISerializedRecentlyOpened & ILegacySerializedRecentlyOpened>(HistoryMainService.recentlyOpenedStorageKey);
265
		const result: IRecentlyOpened = { workspaces: [], files: [] };
266 267 268 269 270 271 272 273 274 275
		if (storedRecents) {
			if (Array.isArray(storedRecents.workspaces2)) {
				for (const workspace of storedRecents.workspaces2) {
					if (isWorkspaceIdentifier(workspace)) {
						result.workspaces.push(workspace);
					} else if (typeof workspace === 'string') {
						result.workspaces.push(URI.parse(workspace));
					}
				}
			} else if (Array.isArray(storedRecents.workspaces)) {
B
Benjamin Pasero 已提交
276
				// TODO@martin legacy support can be removed at some point (6 month?)
277 278 279 280 281 282 283 284 285 286 287 288
				// format of 1.25 and before
				for (const workspace of storedRecents.workspaces) {
					if (typeof workspace === 'string') {
						result.workspaces.push(URI.file(workspace));
					} else if (isWorkspaceIdentifier(workspace)) {
						result.workspaces.push(workspace);
					} else if (workspace && typeof workspace.path === 'string' && typeof workspace.scheme === 'string') {
						// added by 1.26-insiders
						result.workspaces.push(URI.revive(workspace));
					}
				}
			}
289

290 291 292 293 294 295 296
			if (Array.isArray(storedRecents.files2)) {
				for (const file of storedRecents.files2) {
					if (typeof file === 'string') {
						result.files.push(URI.parse(file));
					}
				}
			} else if (Array.isArray(storedRecents.files)) {
297 298
				for (const file of storedRecents.files) {
					if (typeof file === 'string') {
299
						result.files.push(URI.file(file));
300
					}
301
				}
302 303
			}
		}
304

305 306 307
		return result;
	}

308
	private saveRecentlyOpened(recent: IRecentlyOpened): void {
309
		const serialized: ISerializedRecentlyOpened = { workspaces2: [], files2: [] };
310

311
		for (const workspace of recent.workspaces) {
312
			if (isSingleFolderWorkspaceIdentifier(workspace)) {
313
				serialized.workspaces2.push(workspace.toString());
314
			} else {
315
				serialized.workspaces2.push(workspace);
316 317
			}
		}
318

319
		for (const file of recent.files) {
320
			serialized.files2.push(file.toString());
321
		}
322

323
		this.stateService.setItem(HistoryMainService.recentlyOpenedStorageKey, serialized);
324 325
	}

B
Benjamin Pasero 已提交
326
	updateWindowsJumpList(): void {
B
Benjamin Pasero 已提交
327
		if (!isWindows) {
328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348
			return; // only on windows
		}

		const jumpList: Electron.JumpListCategory[] = [];

		// Tasks
		jumpList.push({
			type: 'tasks',
			items: [
				{
					type: 'task',
					title: nls.localize('newWindow', "New Window"),
					description: nls.localize('newWindowDesc', "Opens a new window"),
					program: process.execPath,
					args: '-n', // force new window
					iconPath: process.execPath,
					iconIndex: 0
				}
			]
		});

349 350
		// Recent Workspaces
		if (this.getRecentlyOpened().workspaces.length > 0) {
351 352 353 354 355

			// The user might have meanwhile removed items from the jump list and we have to respect that
			// so we need to update our list of recent paths with the choice of the user to not add them again
			// Also: Windows will not show our custom category at all if there is any entry which was removed
			// by the user! See https://github.com/Microsoft/vscode/issues/15052
356
			let toRemove: Array<ISingleFolderWorkspaceIdentifier | IWorkspaceIdentifier> = [];
357 358 359
			for (let item of app.getJumpListSettings().removedItems) {
				const args = item.args;
				if (args) {
M
Martin Aeschlimann 已提交
360
					const match = /^--folder-uri\s+"([^"]+)"$/.exec(args);
361 362 363 364 365 366 367
					if (match) {
						if (args[0] === '-') {
							toRemove.push(URI.parse(match[1]));
						} else {
							let configPath = match[1];
							toRemove.push({ id: this.workspacesMainService.getWorkspaceId(configPath), configPath });
						}
368 369 370 371
					}
				}
			}
			this.removeFromRecentlyOpened(toRemove);
372 373 374 375

			// Add entries
			jumpList.push({
				type: 'custom',
376
				name: nls.localize('recentFolders', "Recent Workspaces"),
M
Matt Bierner 已提交
377
				items: arrays.coalesce(this.getRecentlyOpened().workspaces.slice(0, 7 /* limit number of entries here */).map(workspace => {
I
isidor 已提交
378
					const title = getSimpleWorkspaceLabel(workspace, this.environmentService.workspacesHome);
379
					let description;
380
					let args;
381
					if (isSingleFolderWorkspaceIdentifier(workspace)) {
M
Martin Aeschlimann 已提交
382
						const parentFolder = dirname(workspace);
I
isidor 已提交
383
						description = parentFolder ? nls.localize('folderDesc', "{0} {1}", getBaseLabel(workspace), getPathLabel(parentFolder, this.environmentService)) : getBaseLabel(workspace);
M
Martin Aeschlimann 已提交
384
						args = `--folder-uri "${workspace.toString()}"`;
385
					} else {
386
						description = nls.localize('codeWorkspace', "Code Workspace");
387 388
						args = `"${workspace.configPath}"`;
					}
389 390
					return <Electron.JumpListItem>{
						type: 'task',
391 392
						title,
						description,
393
						program: process.execPath,
394
						args,
395 396 397
						iconPath: 'explorer.exe', // simulate folder icon
						iconIndex: 0
					};
M
Matt Bierner 已提交
398
				}))
399 400 401 402 403 404 405 406 407 408 409
			});
		}

		// Recent
		jumpList.push({
			type: 'recent' // this enables to show files in the "recent" category
		});

		try {
			app.setJumpList(jumpList);
		} catch (error) {
J
Joao Moreno 已提交
410
			this.logService.warn('#setJumpList', error); // since setJumpList is relatively new API, make sure to guard for errors
411 412
		}
	}
413
}