historyMainService.ts 14.1 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';
11
import { getBaseLabel } 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
import { ILabelService } from 'vs/platform/label/common/label';
23 24

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

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

34 35
export class HistoryMainService implements IHistoryMainService {

36 37
	private static readonly MAX_TOTAL_RECENT_ENTRIES = 100;
	private static readonly MAX_MACOS_DOCK_RECENT_ENTRIES = 10;
38

39
	private static readonly recentlyOpenedStorageKey = 'openedPathsList';
40 41 42

	_serviceBrand: any;

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

46 47
	private macOSRecentDocumentsUpdater: RunOnceScheduler;

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

56 57 58 59
		this.registerListeners();
	}

	private registerListeners(): void {
B
Benjamin Pasero 已提交
60
		this.workspacesMainService.onWorkspaceSaved(e => this.onWorkspaceSaved(e));
61
		this.labelService.onDidRegisterFormatter(() => this._onRecentlyOpenedChange.fire());
62 63 64 65
	}

	private onWorkspaceSaved(e: IWorkspaceSavedEvent): void {

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

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

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

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

B
Benjamin Pasero 已提交
85 86 87 88
					// 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.
				});
			}
89

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

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

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

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

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

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

121
		pathsToRemove.forEach(pathToRemove => {
122 123

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

146
			// Remove file
147 148 149 150 151 152 153 154 155
			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;
			});

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

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

			// 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
190 191 192 193 194 195 196 197 198 199 200 201
		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++;
			}
202 203 204
		}

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

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

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

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

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

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

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

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

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

253
		return { workspaces, files };
254 255
	}

256 257
	private distinctFn(workspaceOrFile: IWorkspaceIdentifier | ISingleFolderWorkspaceIdentifier | URI): string {
		if (workspaceOrFile instanceof URI) {
258 259
			return getComparisonKey(workspaceOrFile);
		}
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
			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)) {
296 297
				for (const file of storedRecents.files) {
					if (typeof file === 'string') {
298
						result.files.push(URI.file(file));
299
					}
300
				}
301 302 303 304 305
			}
		}
		return result;
	}

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

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

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

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

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

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

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