diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index 5a9990b1eaa..059ffcba78d 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -260,14 +260,14 @@ const registry = Registry.as(ConfigurationExtensions.Con 'workbench.localHistory.enabled': { 'type': 'boolean', 'default': true, - 'description': localize('localHistoryEnabled', "Controls whether the local file history is enabled. When enabled, the file contents of an editor that is saved will be stored to a backup location and can be restored or reviewed later. Changing this setting has no effect on existing file history entries."), + 'description': localize('localHistoryEnabled', "Controls whether local file history is enabled. When enabled, the file contents of an editor that is saved will be stored to a backup location to be able to restore or review the contents later. Changing this setting has no effect on existing local file history entries."), 'scope': ConfigurationScope.RESOURCE }, 'workbench.localHistory.maxFileSize': { 'type': 'number', 'default': 256, 'minimum': 1, - 'description': localize('localHistoryMaxFileSize', "Controls the maximum size of a file (in KB) to be considered for local history. Files that are larger will not be added to the local history unless explicitly added by via user gesture. Changing this setting has no effect on existing file history entries."), + 'description': localize('localHistoryMaxFileSize', "Controls the maximum size of a file (in KB) to be considered for local file history. Files that are larger will not be added to the local file history. Changing this setting has no effect on existing local file history entries."), 'scope': ConfigurationScope.RESOURCE }, 'workbench.localHistory.maxFileEntries': { @@ -279,14 +279,14 @@ const registry = Registry.as(ConfigurationExtensions.Con }, 'workbench.localHistory.exclude': { 'type': 'object', - '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."), + 'markdownDescription': localize('exclude', "Configure [glob patterns](https://code.visualstudio.com/docs/editor/codebasics#_advanced-search-options) for excluding files from the local file history. Changing this setting has no effect on existing local file history entries."), '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."), + 'markdownDescription': localize('mergePeriod', "Configure an interval in seconds during which the last entry in local file 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. Changing this setting has no effect on existing local file history entries."), 'scope': ConfigurationScope.RESOURCE }, 'workbench.commandPalette.history': { diff --git a/src/vs/workbench/contrib/localHistory/browser/localHistory.ts b/src/vs/workbench/contrib/localHistory/browser/localHistory.ts index c182f0ccfd1..d63011b9c8d 100644 --- a/src/vs/workbench/contrib/localHistory/browser/localHistory.ts +++ b/src/vs/workbench/contrib/localHistory/browser/localHistory.ts @@ -14,5 +14,6 @@ export const LOCAL_HISTORY_DATE_FORMATTER = new Intl.DateTimeFormat(language, { export const LOCAL_HISTORY_MENU_CONTEXT_VALUE = 'localHistory:item'; export const LOCAL_HISTORY_MENU_CONTEXT_KEY = ContextKeyExpr.equals('timelineItem', LOCAL_HISTORY_MENU_CONTEXT_VALUE); -export const LOCAL_HISTORY_ICON_ENTRY = registerIcon('localHistory-icon', Codicon.circleOutline, localize('localHistoryIcon', "Icon for a local history entry in the timeline view.")); -export const LOCAL_HISTORY_ICON_RESTORE = registerIcon('localHistory-restore', Codicon.check, localize('localHistoryRestore', "Icon for restoring contents of a local history entry.")); +export const LOCAL_HISTORY_ICON_ENTRY = registerIcon('localHistory-entry', Codicon.circleOutline, localize('localHistoryEntryIcon', "Icon for a local history entry in the timeline view.")); +export const LOCAL_HISTORY_ICON_ENTRIES = registerIcon('localHistory-entries', Codicon.circleOutline, localize('localHistoryEntriesIcon', "Icon for a set of local history entries in the timeline view.")); +export const LOCAL_HISTORY_ICON_RESTORE = registerIcon('localHistory-restore', Codicon.check, localize('localHistoryRestoreIcon', "Icon for restoring contents of a local history entry.")); diff --git a/src/vs/workbench/contrib/localHistory/browser/localHistoryTimeline.ts b/src/vs/workbench/contrib/localHistory/browser/localHistoryTimeline.ts index 2ce956d8c86..b58d15ae724 100644 --- a/src/vs/workbench/contrib/localHistory/browser/localHistoryTimeline.ts +++ b/src/vs/workbench/contrib/localHistory/browser/localHistoryTimeline.ts @@ -20,7 +20,7 @@ import { SaveSourceRegistry } from 'vs/workbench/common/editor'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { COMPARE_WITH_FILE_LABEL, toDiffEditorArguments } from 'vs/workbench/contrib/localHistory/browser/localHistoryCommands'; import { MarkdownString } from 'vs/base/common/htmlContent'; -import { LOCAL_HISTORY_DATE_FORMATTER, LOCAL_HISTORY_ICON_ENTRY, LOCAL_HISTORY_MENU_CONTEXT_VALUE } from 'vs/workbench/contrib/localHistory/browser/localHistory'; +import { LOCAL_HISTORY_DATE_FORMATTER, LOCAL_HISTORY_ICON_ENTRIES, LOCAL_HISTORY_ICON_ENTRY, LOCAL_HISTORY_MENU_CONTEXT_VALUE } from 'vs/workbench/contrib/localHistory/browser/localHistory'; import { Schemas } from 'vs/base/common/network'; export class LocalHistoryTimeline extends Disposable implements IWorkbenchContribution, TimelineProvider { @@ -118,12 +118,13 @@ export class LocalHistoryTimeline extends Disposable implements IWorkbenchContri if (resource) { - // Retrieve from working copy history - const entries = await this.workingCopyHistoryService.getEntries(resource, token); + // Aggregate local history entries of same source into + // buckets so that the overall number of timeline entries + // is not too spammy. - // Convert to timeline items - for (const entry of entries) { - items.push(this.toTimelineItem(entry)); + const aggregatedEntries = await this.aggregateLocalHistory(resource, token); + for (const [entry, entries] of aggregatedEntries) { + items.push(this.toTimelineItem(entry, entries)); } } @@ -133,20 +134,58 @@ export class LocalHistoryTimeline extends Disposable implements IWorkbenchContri }; } - private toTimelineItem(entry: IWorkingCopyHistoryEntry): TimelineItem { + private async aggregateLocalHistory(resource: URI, token: CancellationToken): Promise> { + const aggregatedEntries = new Map(); + + const entries = await this.workingCopyHistoryService.getEntries(resource, token); + + let currentHistoryEntry: IWorkingCopyHistoryEntry | undefined = undefined; + for (const entry of entries) { + + // Open new bucket for first entry or when sources differ + if (!currentHistoryEntry || currentHistoryEntry.source !== entry.source) { + currentHistoryEntry = entry; + aggregatedEntries.set(currentHistoryEntry, [currentHistoryEntry]); + } + + // Otherwise add to previous bucket + else { + aggregatedEntries.get(currentHistoryEntry)?.push(entry); + } + } + + return aggregatedEntries; + } + + private toTimelineItem(entry: IWorkingCopyHistoryEntry, aggregatedEntries: IWorkingCopyHistoryEntry[]): TimelineItem { + + // Single entry: return without children + if (aggregatedEntries.length === 1) { + return { + handle: entry.id, + label: SaveSourceRegistry.getSourceLabel(entry.source), + tooltip: new MarkdownString(`$(history) ${LOCAL_HISTORY_DATE_FORMATTER.format(entry.timestamp)}\n\n${SaveSourceRegistry.getSourceLabel(entry.source)}`, { supportThemeIcons: true }), + source: LocalHistoryTimeline.ID, + timestamp: entry.timestamp, + themeIcon: LOCAL_HISTORY_ICON_ENTRY, + contextValue: LOCAL_HISTORY_MENU_CONTEXT_VALUE, + command: { + id: API_OPEN_DIFF_EDITOR_COMMAND_ID, + title: COMPARE_WITH_FILE_LABEL.value, + arguments: toDiffEditorArguments(entry, entry.workingCopy.resource) + } + }; + } + + // Aggregated entry: return with children + const children = aggregatedEntries.map(entry => this.toTimelineItem(entry, [entry])); return { handle: entry.id, - label: SaveSourceRegistry.getSourceLabel(entry.source), - tooltip: new MarkdownString(`$(history) ${LOCAL_HISTORY_DATE_FORMATTER.format(entry.timestamp)}\n\n${SaveSourceRegistry.getSourceLabel(entry.source)}`, { supportThemeIcons: true }), + label: localize('multipleLocalHistoryEntries', "{0} ({1} entries)", SaveSourceRegistry.getSourceLabel(entry.source), children.length), source: LocalHistoryTimeline.ID, timestamp: entry.timestamp, - themeIcon: LOCAL_HISTORY_ICON_ENTRY, - contextValue: LOCAL_HISTORY_MENU_CONTEXT_VALUE, - command: { - id: API_OPEN_DIFF_EDITOR_COMMAND_ID, - title: COMPARE_WITH_FILE_LABEL.value, - arguments: toDiffEditorArguments(entry, entry.workingCopy.resource) - } + themeIcon: LOCAL_HISTORY_ICON_ENTRIES, + children }; } } diff --git a/src/vs/workbench/contrib/timeline/browser/timelinePane.ts b/src/vs/workbench/contrib/timeline/browser/timelinePane.ts index 61c278ca0eb..da11e918afd 100644 --- a/src/vs/workbench/contrib/timeline/browser/timelinePane.ts +++ b/src/vs/workbench/contrib/timeline/browser/timelinePane.ts @@ -694,7 +694,7 @@ export class TimelinePane extends ViewPane { } lastRelativeTime = updateRelativeTime(item, lastRelativeTime); - yield { element: item }; + yield this.toTreeItem(item); } timeline.lastRenderedIndex = count - 1; @@ -754,7 +754,7 @@ export class TimelinePane extends ViewPane { } lastRelativeTime = updateRelativeTime(item, lastRelativeTime); - yield { element: item }; + yield this.toTreeItem(item); } nextSource.nextItem = nextSource.iterator.next(); @@ -774,12 +774,20 @@ export class TimelinePane extends ViewPane { } } + private toTreeItem(item: TimelineItem): ITreeElement { + if (Array.isArray(item.children) && item.children.length > 0) { + return { element: item, children: item.children.map(child => this.toTreeItem(child)) }; + } + + return { element: item }; + } + private refresh() { if (!this.isBodyVisible()) { return; } - this.tree.setChildren(null, this.getItems() as any); + this.tree.setChildren(null, this.getItems()); this._isEmpty = !this.hasVisibleItems; if (this.uri === undefined) { @@ -897,6 +905,7 @@ export class TimelinePane extends ViewPane { } }, keyboardNavigationLabelProvider: new TimelineKeyboardNavigationLabelProvider(), + collapseByDefault: true, multipleSelectionSupport: true, overrideStyles: { listBackground: this.getBackgroundColor(), diff --git a/src/vs/workbench/contrib/timeline/common/timeline.ts b/src/vs/workbench/contrib/timeline/common/timeline.ts index edf2a850a02..db9075ef275 100644 --- a/src/vs/workbench/contrib/timeline/common/timeline.ts +++ b/src/vs/workbench/contrib/timeline/common/timeline.ts @@ -52,6 +52,13 @@ export interface TimelineItem { relativeTime?: string; hideRelativeTime?: boolean; + + /** + * Optional support for grouping multiple timeline items + * under a parent timeline, e.g. to flatten entries of + * the same kind. + */ + children?: TimelineItem[]; } export interface TimelineChangeEvent { diff --git a/src/vs/workbench/services/workingCopy/common/workingCopyHistoryService.ts b/src/vs/workbench/services/workingCopy/common/workingCopyHistoryService.ts index 371ae135510..ac98822ceb8 100644 --- a/src/vs/workbench/services/workingCopy/common/workingCopyHistoryService.ts +++ b/src/vs/workbench/services/workingCopy/common/workingCopyHistoryService.ts @@ -16,7 +16,7 @@ import { FileOperation, FileOperationError, FileOperationEvent, FileOperationRes import { IRemoteAgentService } from 'vs/workbench/services/remote/common/remoteAgentService'; import { URI } from 'vs/base/common/uri'; import { DeferredPromise, Limiter } from 'vs/base/common/async'; -import { extname, isEqual, joinPath } from 'vs/base/common/resources'; +import { dirname, extname, isEqual, joinPath } from 'vs/base/common/resources'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; import { hash } from 'vs/base/common/hash'; import { indexOfPath, randomPath } from 'vs/base/common/extpath'; @@ -47,7 +47,6 @@ export class WorkingCopyHistoryModel { static readonly ENTRIES_FILE = 'entries.json'; private static readonly FILE_SAVED_SOURCE = SaveSourceRegistry.registerSource('default.source', localize('default.source', "File Saved")); - private static readonly FILE_MOVED_SOURCE = SaveSourceRegistry.registerSource('moved.source', localize('moved.source', "File Moved")); private static readonly SETTINGS = { MAX_ENTRIES: 'workbench.localHistory.maxFileEntries', @@ -323,7 +322,7 @@ export class WorkingCopyHistoryModel { return entries; } - async moveEntries(targetWorkingCopyResource: URI, token: CancellationToken): Promise { + async moveEntries(targetWorkingCopyResource: URI, source: SaveSource, token: CancellationToken): Promise { // Ensure model stored so that any pending data is flushed await this.store(token); @@ -347,7 +346,7 @@ export class WorkingCopyHistoryModel { this.setWorkingCopy(targetWorkingCopyResource); // Add entry for the move - await this.addEntry(WorkingCopyHistoryModel.FILE_MOVED_SOURCE, undefined, token); + await this.addEntry(source, undefined, token); // Store model again to updated location await this.store(token); @@ -487,6 +486,9 @@ export class WorkingCopyHistoryModel { export abstract class WorkingCopyHistoryService extends Disposable implements IWorkingCopyHistoryService { + private static readonly FILE_MOVED_SOURCE = SaveSourceRegistry.registerSource('moved.source', localize('moved.source', "File Moved")); + private static readonly FILE_RENAMED_SOURCE = SaveSourceRegistry.registerSource('renamed.source', localize('renamed.source', "File Renamed")); + declare readonly _serviceBrand: undefined; protected readonly _onDidAddEntry = this._register(new Emitter()); @@ -564,6 +566,7 @@ export abstract class WorkingCopyHistoryService extends Disposable implements IW continue; // model does not match moved resource } + // Determine new resulting target resource let targetResource: URI; if (isEqual(source, resource)) { @@ -573,8 +576,16 @@ export abstract class WorkingCopyHistoryService extends Disposable implements IW targetResource = joinPath(target, resource.path.substr(index + source.path.length + 1)); // parent folder got moved } + // Figure out save source + let saveSource: SaveSource; + if (isEqual(dirname(resource), dirname(targetResource))) { + saveSource = WorkingCopyHistoryService.FILE_RENAMED_SOURCE; + } else { + saveSource = WorkingCopyHistoryService.FILE_MOVED_SOURCE; + } + // Move entries to target queued - promises.push(limiter.queue(() => this.moveEntries(model, resource, targetResource))); + promises.push(limiter.queue(() => this.moveEntries(model, saveSource, resource, targetResource))); } if (!promises.length) { @@ -588,10 +599,10 @@ export abstract class WorkingCopyHistoryService extends Disposable implements IW this._onDidMoveEntries.fire(); } - private async moveEntries(model: WorkingCopyHistoryModel, sourceWorkingCopyResource: URI, targetWorkingCopyResource: URI): Promise { + private async moveEntries(model: WorkingCopyHistoryModel, source: SaveSource, sourceWorkingCopyResource: URI, targetWorkingCopyResource: URI): Promise { // Move to target via model - await model.moveEntries(targetWorkingCopyResource, CancellationToken.None); + await model.moveEntries(targetWorkingCopyResource, source, CancellationToken.None); // Update model in our map this.models.delete(sourceWorkingCopyResource);