diff --git a/CHANGELOG.md b/CHANGELOG.md index e154c81..da62766 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ## [Unreleased] +## Added + +- Adds a new experimental _Incoming Activity_ node to each repository in the _Repositories_ view (enabled via `"gitlens.insiders": true`) — closes [#735](https://github.com/eamodio/vscode-gitlens/issues/735) + - **Incoming Activity** — lists the recent incoming activity (merges and pulls) to your local repository + - Provides the activity command, branch (if available), and date + - A context menu provides access to the _Refresh_ command + - Each activity expands to list the commits added by the command + - An inline toolbar provides quick access to the _Compare with HEAD_ (`alt-click` for _Compare with Working Tree_), _Copy Commit ID to Clipboard_ (`alt-click` for _Copy Commit Message to Clipboard_), and _Open Commit on Remote_ (if available) commands + - A context menu provides access to more common revision (commit) commands + - Each revision (commit) expands to list its set of changed files, complete with status indicators for adds, changes, renames, and deletes + - An inline toolbar provides quick access to the _Open File_, _Copy Commit ID to Clipboard_ (`alt-click` for _Copy Commit Message to Clipboard_), and _Open File on Remote_ (if available) commands + - A context menu provides access to more common file revision commands + ## Fixed - Fixes issues with the _Show More Actions_ button on the _Details_ hover not working with renamed files diff --git a/README.md b/README.md index ad27acd..fdf4857 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,17 @@ The repositories view provides the following features, - An inline toolbar provides quick access to the _Open File_, _Copy Commit ID to Clipboard_ (`alt-click` for _Copy Commit Message to Clipboard_), and _Open File on Remote_ (if available) commands - A context menu provides access to more common file revision commands +- **Incoming Activity** — lists the recent incoming activity (merges and pulls) to your local repository (experimental, enabled via `"gitlens.insiders": true`) + + - Provides the activity command, branch (if available), and date + - A context menu provides access to the _Refresh_ command + - Each activity expands to list the commits added by the command + - An inline toolbar provides quick access to the _Compare with HEAD_ (`alt-click` for _Compare with Working Tree_), _Copy Commit ID to Clipboard_ (`alt-click` for _Copy Commit Message to Clipboard_), and _Open Commit on Remote_ (if available) commands + - A context menu provides access to more common revision (commit) commands + - Each revision (commit) expands to list its set of changed files, complete with status indicators for adds, changes, renames, and deletes + - An inline toolbar provides quick access to the _Open File_, _Copy Commit ID to Clipboard_ (`alt-click` for _Copy Commit Message to Clipboard_), and _Open File on Remote_ (if available) commands + - A context menu provides access to more common file revision commands + - **Remotes** — lists the remotes in the repository - Provides the name of each remote, an indicator of the direction of the remote (fetch, push, both), remote service (if applicable), and repository path diff --git a/images/dark/icon-merge.svg b/images/dark/icon-merge.svg new file mode 100644 index 0000000..0cfb815 --- /dev/null +++ b/images/dark/icon-merge.svg @@ -0,0 +1,4 @@ + + + + diff --git a/images/light/icon-merge.svg b/images/light/icon-merge.svg new file mode 100644 index 0000000..9825f12 --- /dev/null +++ b/images/light/icon-merge.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/git/git.ts b/src/git/git.ts index 17b86b0..605dad5 100644 --- a/src/git/git.ts +++ b/src/git/git.ts @@ -806,8 +806,14 @@ export class Git { return git({ cwd: repoPath }, ...params, ref1, ref2); } - static reflog(repoPath: string, { branch, since }: { branch?: string; since?: string } = {}): Promise { + static reflog( + repoPath: string, + { all, branch, since }: { all?: boolean; branch?: string; since?: string } = {} + ): Promise { const params = ['log', '-g', `--format=${GitReflogParser.defaultFormat}`, '--date=unix']; + if (all) { + params.push('--all'); + } if (branch) { params.push(branch); } diff --git a/src/git/gitService.ts b/src/git/gitService.ts index f700a28..463c6ae 100644 --- a/src/git/gitService.ts +++ b/src/git/gitService.ts @@ -95,6 +95,7 @@ export enum GitRepoSearchBy { } const emptyPromise: Promise = Promise.resolve(undefined); +const reflogCommands = ['merge', 'pull']; export class GitService implements Disposable { private _onDidChangeRepositories = new EventEmitter(); @@ -1955,9 +1956,9 @@ export class GitService implements Disposable { } @log() - async getRecentIncomingChanges( + async getIncomingActivity( repoPath: string, - options: { branch?: string; since?: string } = {} + { maxCount, ...options }: { all?: boolean; branch?: string; maxCount?: number; since?: string } = {} ): Promise { const cc = Logger.getCorrelationContext(); @@ -1965,7 +1966,14 @@ export class GitService implements Disposable { const data = await Git.reflog(repoPath, options); if (data === undefined) return undefined; - return GitReflogParser.parseRecentIncomingChanges(data, repoPath); + const reflog = GitReflogParser.parse( + data, + repoPath, + reflogCommands, + maxCount == null ? Container.config.advanced.maxListItems || 0 : maxCount + ); + + return reflog; } catch (ex) { Logger.error(ex, cc); diff --git a/src/git/models/reflog.ts b/src/git/models/reflog.ts index bd7b096..ba4d8af 100644 --- a/src/git/models/reflog.ts +++ b/src/git/models/reflog.ts @@ -1,19 +1,31 @@ 'use strict'; import { Dates, memoize } from '../../system'; -import { CommitFormatting } from '../git'; +import { CommitFormatting, Git } from '../git'; import { DateStyle } from '../../config'; -export class GitReflog { - previousRef: string | undefined; +export interface GitReflog { + readonly repoPath: string; + readonly records: GitReflogRecord[]; + + readonly count: number; + readonly maxCount: number | undefined; + readonly truncated: boolean; +} + +export class GitReflogRecord { + private _previousSha: string | undefined; constructor( public readonly repoPath: string, - public readonly ref: string, + public readonly sha: string, + private _selector: string, public readonly date: Date, - public readonly command: string + public readonly command: string, + public readonly commandArgs: string | undefined, + public readonly details: string | undefined ) {} - @memoize(format => (format == null ? 'MMMM Do, YYYY h:mma' : format)) + @memoize(format => (format == null ? 'MMMM Do, YYYY h:mma' : format)) formatDate(format?: string | null) { if (format == null) { format = 'MMMM Do, YYYY h:mma'; @@ -33,6 +45,48 @@ export class GitReflog { } @memoize() + get HEAD() { + if (this._selector == null || this._selector.length === 0) return ''; + + if (this._selector.startsWith('refs/heads')) { + return this._selector.substr(11); + } + + if (this._selector.startsWith('refs/remotes')) { + return this._selector.substr(13); + } + + return this._selector; + } + + get previousSha() { + return this._previousSha; + } + + @memoize() + get previousShortSha() { + return Git.shortenSha(this._previousSha); + } + + get selector() { + return this._selector; + } + + @memoize() + get shortSha() { + return Git.shortenSha(this.sha); + } + + update(previousSha?: string, selector?: string) { + if (previousSha !== undefined) { + this._previousSha = previousSha; + } + if (selector !== undefined) { + this._selector = selector; + } + } + + @memoize() private get dateFormatter(): Dates.DateFormatter { return Dates.getFormatter(this.date); } diff --git a/src/git/parsers/reflogParser.ts b/src/git/parsers/reflogParser.ts index e57fa6b..6d7abb4 100644 --- a/src/git/parsers/reflogParser.ts +++ b/src/git/parsers/reflogParser.ts @@ -1,9 +1,10 @@ 'use strict'; import { debug } from '../../system'; -import { GitReflog } from '../models/reflog'; +import { GitReflog, GitReflogRecord } from '../models/reflog'; -const incomingCommands = ['merge', 'pull']; -const reflogRegex = /^(.+)(?:.+?)@{(.+)}(\w*).*$/gm; +const reflogRegex = /^(.+)(.+?)@{(.+)}(\w*)(.*?)(?::(.*))?$/gm; +// const reflogRegex = /^(.+)(.+?)@{(.+)}(\w*)(.*?)(?::(.*))?(.*)$/gm; +const reflogHEADRegex = /.*?\/?HEAD$/; // Using %x00 codes because some shells seem to try to expand things if not const lb = '%x3c'; // `%x${'<'.charCodeAt(0).toString(16)}`; @@ -14,49 +15,108 @@ export class GitReflogParser { `${lb}r${rb}%H`, // ref `${lb}d${rb}%gD`, // reflog selector (with UNIX timestamp) `${lb}s${rb}%gs` // reflog subject + // `${lb}n${rb}%D` // ref names ].join(''); @debug({ args: false }) - static parseRecentIncomingChanges(data: string, repoPath: string): GitReflog | undefined { + static parse(data: string, repoPath: string, commands: string[], maxCount: number): GitReflog | undefined { if (!data) return undefined; - let reflog: GitReflog | undefined; + const records: GitReflogRecord[] = []; - let match: RegExpExecArray | null; + let sha; + let selector; let date; - let ref; let command; + let commandArgs; + let details; + + let head; + let headDate; + let headSha; + + let count = 0; + let recordDate; + let record: GitReflogRecord | undefined; + let truncated = false; + let match: RegExpExecArray | null; do { match = reflogRegex.exec(data); if (match == null) break; - [, ref, date, command] = match; + [, sha, selector, date, command, commandArgs, details] = match; + + if (record !== undefined) { + // If the next record has the same sha as the previous, use it if it is not pointing to just HEAD and the previous is + if ( + sha === record.sha && + (date !== recordDate || !reflogHEADRegex.test(record.selector) || reflogHEADRegex.test(selector)) + ) { + continue; + } + + if (sha !== record.sha) { + if ( + head != null && + headDate === recordDate && + headSha == record.sha && + reflogHEADRegex.test(record.selector) + ) { + record.update(sha, head); + } + else { + record.update(sha); + } + + records.push(record); + record = undefined; + recordDate = undefined; + + count++; + if (maxCount !== 0 && count >= maxCount) { + truncated = true; + break; + } + } + } - // If we don't have a reflog, or are still at the same ref with a proper command, save it - if ( - (reflog === undefined || (reflog !== undefined && ref === reflog.ref)) && - incomingCommands.includes(command) - ) { - reflog = new GitReflog( + if (command === 'HEAD') { + head = selector; + headDate = date; + headSha = sha; + + continue; + } + + if (commands.includes(command)) { + record = new GitReflogRecord( repoPath, // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - ` ${ref}`.substr(1), - new Date((date! as any) * 1000), + ` ${sha}`.substr(1), + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + ` ${selector}`.substr(1), + new Date(Number(date) * 1000), // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 - ` ${command}`.substr(1) + ` ${command}`.substr(1), + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + commandArgs == null || commandArgs.length === 0 ? undefined : commandArgs.substr(1), + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + details == null || details.length === 0 ? undefined : details.substr(1) ); - } - else if (reflog !== undefined && ref !== reflog.ref) { - reflog.previousRef = ref; - - break; + recordDate = date; } } while (match != null); // Ensure the regex state is reset reflogRegex.lastIndex = 0; - return reflog; + return { + repoPath: repoPath, + records: records, + count: count, + maxCount: maxCount, + truncated: truncated + }; } } diff --git a/src/views/nodes.ts b/src/views/nodes.ts index 4ee9ff8..902530c 100644 --- a/src/views/nodes.ts +++ b/src/views/nodes.ts @@ -6,6 +6,8 @@ export * from './nodes/branchNode'; export * from './nodes/branchTrackingStatusNode'; export * from './nodes/commitFileNode'; export * from './nodes/commitNode'; +export * from './nodes/compareNode'; +export * from './nodes/compareResultsNode'; export * from './nodes/contributorNode'; export * from './nodes/contributorsNode'; export * from './nodes/fileHistoryNode'; @@ -13,13 +15,12 @@ export * from './nodes/fileHistoryTrackerNode'; export * from './nodes/folderNode'; export * from './nodes/lineHistoryNode'; export * from './nodes/lineHistoryTrackerNode'; -export * from './nodes/recentIncomingChangesNode'; +export * from './nodes/reflogNode'; +export * from './nodes/reflogRecordNode'; export * from './nodes/remoteNode'; export * from './nodes/remotesNode'; export * from './nodes/repositoriesNode'; export * from './nodes/repositoryNode'; -export * from './nodes/compareResultsNode'; -export * from './nodes/compareNode'; export * from './nodes/resultsCommitsNode'; export * from './nodes/resultsFileNode'; export * from './nodes/resultsFilesNode'; diff --git a/src/views/nodes/recentIncomingChangesNode.ts b/src/views/nodes/recentIncomingChangesNode.ts deleted file mode 100644 index 45faea3..0000000 --- a/src/views/nodes/recentIncomingChangesNode.ts +++ /dev/null @@ -1,61 +0,0 @@ -'use strict'; -import { TreeItem, TreeItemCollapsibleState } from 'vscode'; -import { GlyphChars } from '../../constants'; -import { Container } from '../../container'; -import { GitReflog, GitUri } from '../../git/gitService'; -import { Iterables } from '../../system'; -import { ViewWithFiles } from '../viewBase'; -import { CommitNode } from './commitNode'; -import { MessageNode, ShowMoreNode } from './common'; -import { insertDateMarkers } from './helpers'; -import { PageableViewNode, ResourceType, ViewNode } from './viewNode'; - -export class RecentIncomingChangesNode extends ViewNode implements PageableViewNode { - readonly supportsPaging: boolean = true; - maxCount: number | undefined; - - constructor(view: ViewWithFiles, parent: ViewNode, public readonly reflog: GitReflog) { - super(GitUri.fromRepoPath(reflog.repoPath), view, parent); - } - - get id(): string { - return `${this._instanceId}:gitlens:repository(${this.uri.repoPath}):recent-incoming-changes`; - } - - async getChildren(): Promise { - const range = `${this.reflog.previousRef}..${this.reflog.ref}`; - - const log = await Container.git.getLog(this.uri.repoPath!, { - maxCount: this.maxCount !== undefined ? this.maxCount : this.view.config.defaultItemLimit, - ref: range - }); - if (log === undefined) return [new MessageNode(this.view, this, 'No changes')]; - - const children = [ - ...insertDateMarkers(Iterables.map(log.commits.values(), c => new CommitNode(this.view, this, c)), this, 1) - ]; - - if (log.truncated) { - children.push(new ShowMoreNode(this.view, this, 'Commits', children[children.length - 1])); - } - return children; - } - - getTreeItem(): TreeItem { - const item = new TreeItem('Recent incoming changes', TreeItemCollapsibleState.Collapsed); - item.id = this.id; - item.description = `via ${this.reflog.command} ${GlyphChars.Space}${GlyphChars.Dot}${GlyphChars.Space} ${ - this.reflog.formattedDate - }`; - item.contextValue = ResourceType.RecentIncomingChanges; - item.tooltip = `Recent incoming changes via ${this.reflog.command}\n${this.reflog.formatDate()}`; - - // const iconSuffix = ahead ? 'upload' : 'download'; - // item.iconPath = { - // dark: Container.context.asAbsolutePath(`images/dark/icon-${iconSuffix}.svg`), - // light: Container.context.asAbsolutePath(`images/light/icon-${iconSuffix}.svg`) - // }; - - return item; - } -} diff --git a/src/views/nodes/reflogNode.ts b/src/views/nodes/reflogNode.ts new file mode 100644 index 0000000..9d179b4 --- /dev/null +++ b/src/views/nodes/reflogNode.ts @@ -0,0 +1,66 @@ +'use strict'; +import { TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { Container } from '../../container'; +import { GitUri, Repository } from '../../git/gitService'; +import { PageableViewNode, ResourceType, ViewNode } from './viewNode'; +import { RepositoriesView } from '../repositoriesView'; +import { ReflogRecordNode } from './reflogRecordNode'; +import { debug, gate } from '../../system'; +import { MessageNode, ShowMoreNode } from './common'; + +export class ReflogNode extends ViewNode implements PageableViewNode { + readonly supportsPaging: boolean = true; + maxCount: number | undefined; + + private _children: ViewNode[] | undefined; + + constructor(uri: GitUri, view: RepositoriesView, parent: ViewNode, public readonly repo: Repository) { + super(uri, view, parent); + } + + get id(): string { + return `${this._instanceId}:gitlens:repository(${this.repo.path}):reflog`; + } + + async getChildren(): Promise { + if (this._children === undefined) { + const children = []; + + const reflog = await Container.git.getIncomingActivity(this.repo.path, { + all: true, + maxCount: this.maxCount !== undefined ? this.maxCount : this.view.config.defaultItemLimit + }); + if (reflog === undefined || reflog.records.length === 0) { + return [new MessageNode(this.view, this, 'No activity could be found.')]; + } + + children.push(...reflog.records.map(r => new ReflogRecordNode(this.view, this, r))); + + if (reflog.truncated) { + children.push(new ShowMoreNode(this.view, this, 'Activity', children[children.length - 1])); + } + + this._children = children; + } + return this._children; + } + + getTreeItem(): TreeItem { + const item = new TreeItem('Incoming Activity', TreeItemCollapsibleState.Collapsed); + item.id = this.id; + item.contextValue = ResourceType.Reflog; + item.description = 'experimental'; + item.iconPath = { + dark: Container.context.asAbsolutePath('images/dark/icon-merge.svg'), + light: Container.context.asAbsolutePath('images/light/icon-merge.svg') + }; + + return item; + } + + @gate() + @debug() + refresh() { + this._children = undefined; + } +} diff --git a/src/views/nodes/reflogRecordNode.ts b/src/views/nodes/reflogRecordNode.ts new file mode 100644 index 0000000..947a30e --- /dev/null +++ b/src/views/nodes/reflogRecordNode.ts @@ -0,0 +1,67 @@ +'use strict'; +import { TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { GlyphChars } from '../../constants'; +import { Container } from '../../container'; +import { GitReflogRecord, GitUri } from '../../git/gitService'; +import { Iterables } from '../../system'; +import { ViewWithFiles } from '../viewBase'; +import { CommitNode } from './commitNode'; +import { MessageNode, ShowMoreNode } from './common'; +import { PageableViewNode, ResourceType, ViewNode } from './viewNode'; + +export class ReflogRecordNode extends ViewNode implements PageableViewNode { + readonly supportsPaging: boolean = true; + maxCount: number | undefined; + + constructor(view: ViewWithFiles, parent: ViewNode, public readonly record: GitReflogRecord) { + super(GitUri.fromRepoPath(record.repoPath), view, parent); + } + + get id(): string { + return `${this._instanceId}:gitlens:repository(${this.uri.repoPath}):reflog-record(${this.record.sha}|${ + this.record.selector + }|${this.record.command}|${this.record.commandArgs || ''}|${this.record.date.getTime()})`; + } + + async getChildren(): Promise { + const range = `${this.record.previousSha}..${this.record.sha}`; + + const log = await Container.git.getLog(this.uri.repoPath!, { + maxCount: this.maxCount !== undefined ? this.maxCount : this.view.config.defaultItemLimit, + ref: range + }); + if (log === undefined) return [new MessageNode(this.view, this, 'No commits')]; + + const children: (CommitNode | ShowMoreNode)[] = [ + ...Iterables.map(log.commits.values(), c => new CommitNode(this.view, this, c)) + ]; + + if (log.truncated) { + children.push(new ShowMoreNode(this.view, this, 'Commits', children[children.length - 1])); + } + return children; + } + + getTreeItem(): TreeItem { + const item = new TreeItem( + `${this.record.command}${this.record.commandArgs ? ` ${this.record.commandArgs}` : ''}`, + TreeItemCollapsibleState.Collapsed + ); + item.id = this.id; + item.description = `${ + this.record.HEAD.length === 0 + ? '' + : `${this.record.HEAD} ${GlyphChars.Space}${GlyphChars.Dot}${GlyphChars.Space} ` + }${this.record.formattedDate}`; + item.contextValue = ResourceType.ReflogRecord; + item.tooltip = `${this.record.HEAD.length === 0 ? '' : `${this.record.HEAD}\n`}${this.record.command}${ + this.record.commandArgs ? ` ${this.record.commandArgs}` : '' + }${ + this.record.details ? ` (${this.record.details})` : '' + }\n${this.record.formatDateFromNow()} (${this.record.formatDate()})\n${this.record.previousShortSha} ${ + GlyphChars.Space + }${GlyphChars.ArrowRight}${GlyphChars.Space} ${this.record.shortSha}`; + + return item; + } +} diff --git a/src/views/nodes/repositoryNode.ts b/src/views/nodes/repositoryNode.ts index d2e2ff9..e7a010c 100644 --- a/src/views/nodes/repositoryNode.ts +++ b/src/views/nodes/repositoryNode.ts @@ -18,7 +18,7 @@ import { BranchNode } from './branchNode'; import { BranchTrackingStatusNode } from './branchTrackingStatusNode'; import { MessageNode } from './common'; import { ContributorsNode } from './contributorsNode'; -import { RecentIncomingChangesNode } from './recentIncomingChangesNode'; +import { ReflogNode } from './reflogNode'; import { RemotesNode } from './remotesNode'; import { StashesNode } from './stashesNode'; import { StatusFilesNode } from './statusFilesNode'; @@ -47,17 +47,6 @@ export class RepositoryNode extends SubscribeableViewNode { const children = []; const status = await this._status; - - if (Container.config.insiders) { - const reflog = await Container.git.getRecentIncomingChanges(this.uri.repoPath!, { - // branch: status !== undefined ? status.branch : undefined, - since: '1 month ago' - }); - if (reflog !== undefined) { - children.push(new RecentIncomingChangesNode(this.view, this, reflog)); - } - } - if (status !== undefined) { const branch = new GitBranch( status.repoPath, @@ -92,7 +81,14 @@ export class RepositoryNode extends SubscribeableViewNode { children.push( new BranchesNode(this.uri, this.view, this, this.repo), - new ContributorsNode(this.uri, this.view, this, this.repo), + new ContributorsNode(this.uri, this.view, this, this.repo) + ); + + if (Container.config.insiders) { + children.push(new ReflogNode(this.uri, this.view, this, this.repo)); + } + + children.push( new RemotesNode(this.uri, this.view, this, this.repo), new StashesNode(this.uri, this.view, this, this.repo), new TagsNode(this.uri, this.view, this, this.repo) diff --git a/src/views/nodes/viewNode.ts b/src/views/nodes/viewNode.ts index 7fa09cf..3aa9cf9 100644 --- a/src/views/nodes/viewNode.ts +++ b/src/views/nodes/viewNode.ts @@ -27,7 +27,8 @@ export enum ResourceType { LineHistory = 'gitlens:history:line', Message = 'gitlens:message', Pager = 'gitlens:pager', - RecentIncomingChanges = 'gitlens:recent-incoming-changes', + Reflog = 'gitlens:reflog', + ReflogRecord = 'gitlens:reflog-record', Remote = 'gitlens:remote', Remotes = 'gitlens:remotes', Repositories = 'gitlens:repositories',