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

'use strict';

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

interface ISerializedRecentlyOpened {
28
	workspaces2: (IWorkspaceIdentifier | string)[]; // IWorkspaceIdentifier or URI.toString()
29
	files2: string[]; // files as URI.toString()
30
}
31

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

37 38
export class HistoryMainService implements IHistoryMainService {

39 40
	private static readonly MAX_TOTAL_RECENT_ENTRIES = 100;
	private static readonly MAX_MACOS_DOCK_RECENT_ENTRIES = 10;
41

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

	_serviceBrand: any;

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

49 50
	private macOSRecentDocumentsUpdater: RunOnceScheduler;

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

60 61 62 63
		this.registerListeners();
	}

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

	private onWorkspaceSaved(e: IWorkspaceSavedEvent): void {

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

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

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

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

B
Benjamin Pasero 已提交
88 89 90 91
					// 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.
				});
			}
92

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

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

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

110
			this.saveRecentlyOpened(mru);
111
			this._onRecentlyOpenedChange.fire();
112 113 114 115 116

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

120
	removeFromRecentlyOpened(pathsToRemove: (IWorkspaceIdentifier | ISingleFolderWorkspaceIdentifier | URI | string)[]): void {
B
Benjamin Pasero 已提交
121
		const mru = this.getRecentlyOpened();
122 123
		let update = false;

124
		pathsToRemove.forEach((pathToRemove => {
125 126

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

149
			// Remove file
150 151 152 153
			const pathToRemoveURI = pathToRemove instanceof URI ? pathToRemove : typeof pathToRemove === 'string' ? URI.file(pathToRemove) : null;
			if (pathToRemoveURI) {
				index = arrays.firstIndex(mru.files, file => areResourcesEqual(file, pathToRemoveURI, hasToIgnoreCase(pathToRemoveURI)));
			}
154 155 156
			if (index >= 0) {
				mru.files.splice(index, 1);
				update = true;
157
			}
158
		}));
159 160

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

			// 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();

		let maxEntries = HistoryMainService.MAX_MACOS_DOCK_RECENT_ENTRIES;

		// Take up to maxEntries/2 workspaces
188 189 190 191 192 193 194 195 196 197 198 199
		let nEntries = 0;
		for (let i = 0; i < mru.workspaces.length && nEntries < HistoryMainService.MAX_MACOS_DOCK_RECENT_ENTRIES / 2; i++) {
			const workspace = mru.workspaces[i];
			if (isSingleFolderWorkspaceIdentifier(workspace)) {
				if (workspace.scheme === Schemas.file) {
					app.addRecentDocument(workspace.fsPath);
					nEntries++;
				}
			} else {
				app.addRecentDocument(workspace.configPath);
				nEntries++;
			}
200 201 202
		}

		// Take up to maxEntries files
203
		for (let i = 0; i < mru.files.length && nEntries < maxEntries; i++) {
204
			const file = mru.files[i];
205 206 207 208
			if (file.scheme === Schemas.file) {
				app.addRecentDocument(file.fsPath);
				nEntries++;
			}
209 210 211
		}
	}

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

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

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

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

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

239
		// Add currently files to open to the beginning if any
240
		if (currentFiles) {
241
			files.unshift(...currentFiles.map(f => f.fileUri));
242 243 244
		}

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

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

251
		return { workspaces, files };
252 253
	}

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

261
	private getRecentlyOpenedFromStorage(): IRecentlyOpened {
262
		const storedRecents = this.stateService.getItem<ISerializedRecentlyOpened & ILegacySerializedRecentlyOpened>(HistoryMainService.recentlyOpenedStorageKey);
263
		const result: IRecentlyOpened = { workspaces: [], files: [] };
264 265 266 267 268 269 270 271 272 273
		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)) {
274
				// TODO legacy support can be removed at some point (6 month?)
275 276 277 278 279 280 281 282 283 284 285 286
				// 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));
					}
				}
			}
287 288 289 290 291 292 293
			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)) {
294 295
				for (const file of storedRecents.files) {
					if (typeof file === 'string') {
296
						result.files.push(URI.file(file));
297
					}
298
				}
299 300 301 302 303
			}
		}
		return result;
	}

304
	private saveRecentlyOpened(recent: IRecentlyOpened): void {
305
		const serialized: ISerializedRecentlyOpened = { workspaces2: [], files2: [] };
306
		for (const workspace of recent.workspaces) {
307
			if (isSingleFolderWorkspaceIdentifier(workspace)) {
308
				serialized.workspaces2.push(workspace.toString());
309
			} else {
310
				serialized.workspaces2.push(workspace);
311 312
			}
		}
313
		for (const file of recent.files) {
314
			serialized.files2.push(file.toString());
315
		}
316
		this.stateService.setItem(HistoryMainService.recentlyOpenedStorageKey, serialized);
317 318
	}

B
Benjamin Pasero 已提交
319
	updateWindowsJumpList(): void {
B
Benjamin Pasero 已提交
320
		if (!isWindows) {
321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341
			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
				}
			]
		});

342 343
		// Recent Workspaces
		if (this.getRecentlyOpened().workspaces.length > 0) {
344 345 346 347 348

			// 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
349 350 351 352
			let toRemove: (ISingleFolderWorkspaceIdentifier | IWorkspaceIdentifier)[] = [];
			for (let item of app.getJumpListSettings().removedItems) {
				const args = item.args;
				if (args) {
353 354 355 356 357 358 359 360
					const match = /^--folderUri\s+"([^"]+)"$/.exec(args);
					if (match) {
						if (args[0] === '-') {
							toRemove.push(URI.parse(match[1]));
						} else {
							let configPath = match[1];
							toRemove.push({ id: this.workspacesMainService.getWorkspaceId(configPath), configPath });
						}
361 362 363 364
					}
				}
			}
			this.removeFromRecentlyOpened(toRemove);
365 366 367 368

			// Add entries
			jumpList.push({
				type: 'custom',
369 370
				name: nls.localize('recentFolders', "Recent Workspaces"),
				items: this.getRecentlyOpened().workspaces.slice(0, 7 /* limit number of entries here */).map(workspace => {
371
					const title = getWorkspaceLabel(workspace, this.environmentService, this.uriDisplayService);
372
					let description;
373
					let args;
374
					if (isSingleFolderWorkspaceIdentifier(workspace)) {
375 376
						description = nls.localize('folderDesc', "{0} {1}", getBaseLabel(workspace), this.uriDisplayService.getLabel(dirname(workspace)));
						args = `--folderUri "${workspace.toString()}"`;
377
					} else {
378
						description = nls.localize('codeWorkspace', "Code Workspace");
379 380
						args = `"${workspace.configPath}"`;
					}
381 382
					return <Electron.JumpListItem>{
						type: 'task',
383 384
						title,
						description,
385
						program: process.execPath,
386
						args,
387 388 389 390 391 392 393 394 395 396 397 398 399 400 401
						iconPath: 'explorer.exe', // simulate folder icon
						iconIndex: 0
					};
				}).filter(i => !!i)
			});
		}

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

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