diff --git a/src/vs/base/common/arrays.ts b/src/vs/base/common/arrays.ts index 432c5f62ee4..d10039a556f 100644 --- a/src/vs/base/common/arrays.ts +++ b/src/vs/base/common/arrays.ts @@ -380,6 +380,12 @@ export function firstOrDefault(array: ReadonlyArray, notFoun return array.length > 0 ? array[0] : notFoundValue; } +export function lastOrDefault(array: ReadonlyArray, notFoundValue: NotFound): T | NotFound; +export function lastOrDefault(array: ReadonlyArray): T | undefined; +export function lastOrDefault(array: ReadonlyArray, notFoundValue?: NotFound): T | NotFound | undefined { + return array.length > 0 ? array[array.length - 1] : notFoundValue; +} + export function commonPrefixLength(one: ReadonlyArray, other: ReadonlyArray, equals: (a: T, b: T) => boolean = (a, b) => a === b): number { let result = 0; diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index 28210ca4cf8..5a9990b1eaa 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -282,6 +282,13 @@ const registry = Registry.as(ConfigurationExtensions.Con 'markdownDescription': localize('exclude', "Configure [glob patterns](https://code.visualstudio.com/docs/editor/codebasics#_advanced-search-options) for excluding files from being added to local history."), 'scope': ConfigurationScope.RESOURCE }, + 'workbench.localHistory.mergePeriod': { + 'type': 'number', + 'default': 10, + 'minimum': 1, + 'markdownDescription': localize('mergePeriod', "Configure an interval in seconds during which the last entry in local history is replaced with the entry that is being added. This helps reduce the overall number of entries that are added, for example when auto save is enabled. This setting is only applied to entries that have the same source of origin."), + 'scope': ConfigurationScope.RESOURCE + }, 'workbench.commandPalette.history': { 'type': 'number', 'description': localize('commandHistory', "Controls the number of recently used commands to keep in history for the command palette. Set to 0 to disable command history."), diff --git a/src/vs/workbench/services/workingCopy/common/workingCopyHistory.ts b/src/vs/workbench/services/workingCopy/common/workingCopyHistory.ts index 5c67f1dd5a0..dd3aa74633f 100644 --- a/src/vs/workbench/services/workingCopy/common/workingCopyHistory.ts +++ b/src/vs/workbench/services/workingCopy/common/workingCopyHistory.ts @@ -42,7 +42,7 @@ export interface IWorkingCopyHistoryEntry { /** * The time when this history entry was created. */ - readonly timestamp: number; + timestamp: number; /** * Associated source with the history entry. diff --git a/src/vs/workbench/services/workingCopy/common/workingCopyHistoryService.ts b/src/vs/workbench/services/workingCopy/common/workingCopyHistoryService.ts index b1094b7ff65..835938ec1f0 100644 --- a/src/vs/workbench/services/workingCopy/common/workingCopyHistoryService.ts +++ b/src/vs/workbench/services/workingCopy/common/workingCopyHistoryService.ts @@ -27,6 +27,7 @@ import { VSBuffer } from 'vs/base/common/buffer'; import { ILogService } from 'vs/platform/log/common/log'; import { SaveSource, SaveSourceRegistry } from 'vs/workbench/common/editor'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { lastOrDefault } from 'vs/base/common/arrays'; interface ISerializedWorkingCopyHistoryModel { readonly version: number; @@ -46,7 +47,10 @@ export class WorkingCopyHistoryModel { private static readonly DEFAULT_ENTRY_SOURCE = SaveSourceRegistry.registerSource('default.source', localize('default.source', "File Saved")); - private static readonly MAX_ENTRIES_SETTINGS_KEY = 'workbench.localHistory.maxFileEntries'; + private static readonly SETTINGS = { + MAX_ENTRIES: 'workbench.localHistory.maxFileEntries', + MERGE_PERIOD: 'workbench.localHistory.mergePeriod' + }; private entries: IWorkingCopyHistoryEntry[] = []; @@ -74,11 +78,34 @@ export class WorkingCopyHistoryModel { } async addEntry(source = WorkingCopyHistoryModel.DEFAULT_ENTRY_SOURCE, timestamp = Date.now(), token: CancellationToken): Promise { + let entryToReplace: IWorkingCopyHistoryEntry | undefined = undefined; - // Clone to a potentially unique location within - // the history entries folder. The idea is to - // execute this as fast as possible, tolerating - // naming collisions, even though unlikely. + // Figure out if the last entry should be replaced based + // on settings that can define a interval for when an + // entry is not added as new entry but should replace. + // However, when save source is different, never replace. + const lastEntry = lastOrDefault(this.entries); + if (lastEntry && lastEntry.source === source) { + const configuredReplaceInterval = this.configurationService.getValue(WorkingCopyHistoryModel.SETTINGS.MERGE_PERIOD, { resource: this.workingCopyResource }); + if (timestamp - lastEntry.timestamp <= (configuredReplaceInterval * 1000 /* convert to millies */)) { + entryToReplace = lastEntry; + } + } + + // Replace lastest entry in history + if (entryToReplace) { + return this.doReplaceEntry(entryToReplace, timestamp, token); + } + + // Add entry to history + else { + return this.doAddEntry(source, timestamp, token); + } + } + + private async doAddEntry(source: SaveSource, timestamp: number, token: CancellationToken): Promise { + + // Perform a fast clone operation with minimal overhead to a new random location const id = `${randomPath(undefined, undefined, 4)}${extname(this.workingCopyResource)}`; const location = joinPath(this.historyEntriesFolder, id); await this.fileService.cloneFile(this.workingCopyResource, location); @@ -102,6 +129,23 @@ export class WorkingCopyHistoryModel { return entry; } + private async doReplaceEntry(entry: IWorkingCopyHistoryEntry, timestamp: number, token: CancellationToken): Promise { + + // Perform a fast clone operation with minimal overhead to the existing location + await this.fileService.cloneFile(this.workingCopyResource, entry.location); + + // Update entry + entry.timestamp = timestamp; + + // Mark as in need to be stored to disk + this.shouldStore = true; + + // Events + this.entryChangedEmitter.fire({ entry }); + + return entry; + } + async removeEntry(entry: IWorkingCopyHistoryEntry, token: CancellationToken): Promise { // Make sure to await resolving when removing entries @@ -161,7 +205,7 @@ export class WorkingCopyHistoryModel { await this.resolveEntriesOnce(); // Return as many entries as configured by user settings - const configuredMaxEntries = this.configurationService.getValue(WorkingCopyHistoryModel.MAX_ENTRIES_SETTINGS_KEY, { resource: this.workingCopyResource }); + const configuredMaxEntries = this.configurationService.getValue(WorkingCopyHistoryModel.SETTINGS.MAX_ENTRIES, { resource: this.workingCopyResource }); if (this.entries.length > configuredMaxEntries) { return this.entries.slice(this.entries.length - configuredMaxEntries); } @@ -279,7 +323,7 @@ export class WorkingCopyHistoryModel { } private async cleanUpEntries(): Promise { - const configuredMaxEntries = this.configurationService.getValue(WorkingCopyHistoryModel.MAX_ENTRIES_SETTINGS_KEY, { resource: this.workingCopyResource }); + const configuredMaxEntries = this.configurationService.getValue(WorkingCopyHistoryModel.SETTINGS.MAX_ENTRIES, { resource: this.workingCopyResource }); if (this.entries.length <= configuredMaxEntries) { return; // nothing to cleanup } diff --git a/src/vs/workbench/services/workingCopy/test/electron-browser/workingCopyHistoryService.test.ts b/src/vs/workbench/services/workingCopy/test/electron-browser/workingCopyHistoryService.test.ts index b733d44d929..a2602a98228 100644 --- a/src/vs/workbench/services/workingCopy/test/electron-browser/workingCopyHistoryService.test.ts +++ b/src/vs/workbench/services/workingCopy/test/electron-browser/workingCopyHistoryService.test.ts @@ -628,4 +628,34 @@ flakySuite('WorkingCopyHistoryService', () => { assertEntryEqual(entries[1], entry4); assertEntryEqual(entries[2], entry5); }); + + test('entries are merged when source is same', async () => { + let changed: IWorkingCopyHistoryEntry | undefined = undefined; + service.onDidChangeEntry(e => changed = e.entry); + + const workingCopy1 = new TestWorkingCopy(URI.file(testFile1Path)); + + service._configurationService.setUserConfiguration('workbench.localHistory.mergePeriod', 1); + + const entry1 = await addEntry({ resource: workingCopy1.resource, source: 'test-source' }, CancellationToken.None); + assert.strictEqual(changed, undefined); + + const entry2 = await addEntry({ resource: workingCopy1.resource, source: 'test-source' }, CancellationToken.None); + assert.strictEqual(changed, entry1); + + const entry3 = await addEntry({ resource: workingCopy1.resource, source: 'test-source' }, CancellationToken.None); + assert.strictEqual(changed, entry2); + + let entries = await service.getEntries(workingCopy1.resource, CancellationToken.None); + assert.strictEqual(entries.length, 1); + assertEntryEqual(entries[0], entry3); + + service._configurationService.setUserConfiguration('workbench.localHistory.mergePeriod', undefined); + + await addEntry({ resource: workingCopy1.resource, source: 'test-source' }, CancellationToken.None); + await addEntry({ resource: workingCopy1.resource, source: 'test-source' }, CancellationToken.None); + + entries = await service.getEntries(workingCopy1.resource, CancellationToken.None); + assert.strictEqual(entries.length, 3); + }); });