Skip to content

Commit 87c2332

Browse files
Eric Amodioeamodio
authored andcommitted
Adds timeline diff on click and icon support
1 parent 70e1e9b commit 87c2332

17 files changed

Lines changed: 421 additions & 245 deletions

File tree

extensions/git/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,11 @@
447447
"command": "git.stashDrop",
448448
"title": "%command.stashDrop%",
449449
"category": "Git"
450+
},
451+
{
452+
"command": "git.openDiff",
453+
"title": "%command.openDiff%",
454+
"category": "Git"
450455
}
451456
],
452457
"menus": {

extensions/git/package.nls.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
"command.stashApply": "Apply Stash...",
7171
"command.stashApplyLatest": "Apply Latest Stash",
7272
"command.stashDrop": "Drop Stash...",
73+
"command.openDiff": "Open Comparison",
7374
"config.enabled": "Whether git is enabled.",
7475
"config.path": "Path and filename of the git executable, e.g. `C:\\Program Files\\Git\\bin\\git.exe` (Windows).",
7576
"config.autoRepositoryDetection": "Configures when repositories should be automatically detected.",

extensions/git/src/api/git.d.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ export interface Commit {
4141
readonly hash: string;
4242
readonly message: string;
4343
readonly parents: string[];
44-
readonly authorEmail?: string | undefined;
44+
readonly authorDate?: Date;
45+
readonly authorName?: string;
46+
readonly authorEmail?: string;
4547
}
4648

4749
export interface Submodule {
@@ -119,6 +121,14 @@ export interface LogOptions {
119121
readonly maxEntries?: number;
120122
}
121123

124+
/**
125+
* Log file options.
126+
*/
127+
export interface LogFileOptions {
128+
/** Max number of log entries to retrieve. If not specified, the default is 32. */
129+
readonly maxEntries?: number;
130+
}
131+
122132
export interface Repository {
123133

124134
readonly rootUri: Uri;

extensions/git/src/commands.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2332,6 +2332,11 @@ export class CommandCenter {
23322332
return result && result.stash;
23332333
}
23342334

2335+
@command('git.openDiff', { repository: false })
2336+
async openDiff(uri: Uri, hash: string) {
2337+
return commands.executeCommand('vscode.diff', toGitUri(uri, hash), toGitUri(uri, `${hash}^`));
2338+
}
2339+
23352340
private createCommand(id: string, key: string, method: Function, options: CommandOptions): (...args: any[]) => any {
23362341
const result = (...args: any[]) => {
23372342
let result: Promise<any>;

extensions/git/src/git.ts

Lines changed: 61 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ import { EventEmitter } from 'events';
1212
import iconv = require('iconv-lite');
1313
import * as filetype from 'file-type';
1414
import { assign, groupBy, IDisposable, toDisposable, dispose, mkdirp, readBytes, detectUnicodeEncoding, Encoding, onceEvent, splitInChunks, Limiter } from './util';
15-
import { CancellationToken, Progress } from 'vscode';
15+
import { CancellationToken, Progress, Uri } from 'vscode';
1616
import { URI } from 'vscode-uri';
1717
import { detectEncoding } from './encoding';
18-
import { Ref, RefType, Branch, Remote, GitErrorCodes, LogOptions, Change, Status } from './api/git';
18+
import { Ref, RefType, Branch, Remote, GitErrorCodes, LogOptions, Change, Status, LogFileOptions } from './api/git';
1919
import * as byline from 'byline';
2020
import { StringDecoder } from 'string_decoder';
2121

@@ -318,7 +318,7 @@ function getGitErrorCode(stderr: string): string | undefined {
318318
return undefined;
319319
}
320320

321-
const COMMIT_FORMAT = '%H\n%ae\n%P\n%B';
321+
const COMMIT_FORMAT = '%H\n%aN\n%aE\n%at\n%P\n%B';
322322

323323
export class Git {
324324

@@ -503,7 +503,9 @@ export interface Commit {
503503
hash: string;
504504
message: string;
505505
parents: string[];
506-
authorEmail?: string | undefined;
506+
authorDate?: Date;
507+
authorName?: string;
508+
authorEmail?: string;
507509
}
508510

509511
export class GitStatusParser {
@@ -634,14 +636,43 @@ export function parseGitmodules(raw: string): Submodule[] {
634636
return result;
635637
}
636638

637-
export function parseGitCommit(raw: string): Commit | null {
638-
const match = /^([0-9a-f]{40})\n(.*)\n(.*)(\n([^]*))?$/m.exec(raw.trim());
639-
if (!match) {
640-
return null;
641-
}
639+
const commitRegex = /([0-9a-f]{40})\n(.*)\n(.*)\n(.*)\n(.*)(?:\n([^]*?))?(?:\x00)/gm;
640+
641+
export function parseGitCommits(data: string): Commit[] {
642+
let commits: Commit[] = [];
643+
644+
let ref;
645+
let name;
646+
let email;
647+
let date;
648+
let parents;
649+
let message;
650+
let match;
651+
652+
do {
653+
match = commitRegex.exec(data);
654+
if (match === null) {
655+
break;
656+
}
657+
658+
[, ref, name, email, date, parents, message] = match;
642659

643-
const parents = match[3] ? match[3].split(' ') : [];
644-
return { hash: match[1], message: match[5], parents, authorEmail: match[2] };
660+
if (message[message.length - 1] === '\n') {
661+
message = message.substr(0, message.length - 1);
662+
}
663+
664+
// Stop excessive memory usage by using substr -- https://bugs.chromium.org/p/v8/issues/detail?id=2869
665+
commits.push({
666+
hash: ` ${ref}`.substr(1),
667+
message: ` ${message}`.substr(1),
668+
parents: parents ? parents.split(' ') : [],
669+
authorDate: new Date(Number(date) * 1000),
670+
authorName: ` ${name}`.substr(1),
671+
authorEmail: ` ${email}`.substr(1)
672+
});
673+
} while (true);
674+
675+
return commits;
645676
}
646677

647678
interface LsTreeElement {
@@ -760,38 +791,28 @@ export class Repository {
760791

761792
async log(options?: LogOptions): Promise<Commit[]> {
762793
const maxEntries = options && typeof options.maxEntries === 'number' && options.maxEntries > 0 ? options.maxEntries : 32;
763-
const args = ['log', '-' + maxEntries, `--pretty=format:${COMMIT_FORMAT}%x00%x00`];
794+
const args = ['log', '-' + maxEntries, `--format:${COMMIT_FORMAT}`, '-z'];
764795

765-
const gitResult = await this.run(args);
766-
if (gitResult.exitCode) {
796+
const result = await this.run(args);
797+
if (result.exitCode) {
767798
// An empty repo
768799
return [];
769800
}
770801

771-
const s = gitResult.stdout;
772-
const result: Commit[] = [];
773-
let index = 0;
774-
while (index < s.length) {
775-
let nextIndex = s.indexOf('\x00\x00', index);
776-
if (nextIndex === -1) {
777-
nextIndex = s.length;
778-
}
779-
780-
let entry = s.substr(index, nextIndex - index);
781-
if (entry.startsWith('\n')) {
782-
entry = entry.substring(1);
783-
}
802+
return parseGitCommits(result.stdout);
803+
}
784804

785-
const commit = parseGitCommit(entry);
786-
if (!commit) {
787-
break;
788-
}
805+
async logFile(uri: Uri, options?: LogFileOptions): Promise<Commit[]> {
806+
const maxEntries = options?.maxEntries ?? 32;
807+
const args = ['log', `-${maxEntries}`, `--format=${COMMIT_FORMAT}`, '-z', '--', uri.fsPath];
789808

790-
result.push(commit);
791-
index = nextIndex + 2;
809+
const result = await this.run(args);
810+
if (result.exitCode) {
811+
// No file history, e.g. a new file or untracked
812+
return [];
792813
}
793814

794-
return result;
815+
return parseGitCommits(result.stdout);
795816
}
796817

797818
async bufferString(object: string, encoding: string = 'utf8', autoGuessEncoding = false): Promise<string> {
@@ -1853,8 +1874,12 @@ export class Repository {
18531874
}
18541875

18551876
async getCommit(ref: string): Promise<Commit> {
1856-
const result = await this.run(['show', '-s', `--format=${COMMIT_FORMAT}`, ref]);
1857-
return parseGitCommit(result.stdout) || Promise.reject<Commit>('bad commit format');
1877+
const result = await this.run(['show', '-s', `--format=${COMMIT_FORMAT}`, '-z', ref]);
1878+
const commits = parseGitCommits(result.stdout);
1879+
if (commits.length === 0) {
1880+
return Promise.reject<Commit>('bad commit format');
1881+
}
1882+
return commits[0];
18581883
}
18591884

18601885
async updateSubmodules(paths: string[]): Promise<void> {

extensions/git/src/main.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { GitExtensionImpl } from './api/extension';
2222
import * as path from 'path';
2323
import * as fs from 'fs';
2424
import { createIPCServer, IIPCServer } from './ipc/ipcServer';
25+
import { GitTimelineProvider } from './timelineProvider';
2526

2627
const deactivateTasks: { (): Promise<any>; }[] = [];
2728

@@ -82,7 +83,8 @@ async function createModel(context: ExtensionContext, outputChannel: OutputChann
8283
new GitContentProvider(model),
8384
new GitFileSystemProvider(model),
8485
new GitDecorations(model),
85-
new GitProtocolHandler()
86+
new GitProtocolHandler(),
87+
new GitTimelineProvider(model)
8688
);
8789

8890
await checkGitVersion(info);

extensions/git/src/repository.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import * as fs from 'fs';
77
import * as path from 'path';
88
import { CancellationToken, Command, Disposable, Event, EventEmitter, Memento, OutputChannel, ProgressLocation, ProgressOptions, scm, SourceControl, SourceControlInputBox, SourceControlInputBoxValidation, SourceControlInputBoxValidationType, SourceControlResourceDecorations, SourceControlResourceGroup, SourceControlResourceState, ThemeColor, Uri, window, workspace, WorkspaceEdit, Decoration } from 'vscode';
99
import * as nls from 'vscode-nls';
10-
import { Branch, Change, GitErrorCodes, LogOptions, Ref, RefType, Remote, Status } from './api/git';
10+
import { Branch, Change, GitErrorCodes, LogOptions, Ref, RefType, Remote, Status, LogFileOptions } from './api/git';
1111
import { AutoFetcher } from './autofetch';
1212
import { debounce, memoize, throttle } from './decorators';
1313
import { Commit, CommitOptions, ForcePushMode, GitError, Repository as BaseRepository, Stash, Submodule } from './git';
@@ -304,6 +304,7 @@ export const enum Operation {
304304
Apply = 'Apply',
305305
Blame = 'Blame',
306306
Log = 'Log',
307+
LogFile = 'LogFile',
307308
}
308309

309310
function isReadOnly(operation: Operation): boolean {
@@ -868,6 +869,11 @@ export class Repository implements Disposable {
868869
return this.run(Operation.Log, () => this.repository.log(options));
869870
}
870871

872+
logFile(uri: Uri, options?: LogFileOptions): Promise<Commit[]> {
873+
// TODO: This probably needs per-uri granularity
874+
return this.run(Operation.LogFile, () => this.repository.logFile(uri, options));
875+
}
876+
871877
@throttle
872878
async status(): Promise<void> {
873879
await this.run(Operation.Status);

extensions/git/src/test/git.test.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import 'mocha';
7-
import { GitStatusParser, parseGitCommit, parseGitmodules, parseLsTree, parseLsFiles } from '../git';
7+
import { GitStatusParser, parseGitCommits, parseGitmodules, parseLsTree, parseLsFiles } from '../git';
88
import * as assert from 'assert';
99
import { splitInChunks } from '../util';
1010

@@ -191,42 +191,42 @@ suite('git', () => {
191191
const GIT_OUTPUT_SINGLE_PARENT = `52c293a05038d865604c2284aa8698bd087915a1
192192
john.doe@mail.com
193193
8e5a374372b8393906c7e380dbb09349c5385554
194-
This is a commit message.`;
194+
This is a commit message.\x00`;
195195

196-
assert.deepEqual(parseGitCommit(GIT_OUTPUT_SINGLE_PARENT), {
196+
assert.deepEqual(parseGitCommits(GIT_OUTPUT_SINGLE_PARENT), [{
197197
hash: '52c293a05038d865604c2284aa8698bd087915a1',
198198
message: 'This is a commit message.',
199199
parents: ['8e5a374372b8393906c7e380dbb09349c5385554'],
200200
authorEmail: 'john.doe@mail.com',
201-
});
201+
}]);
202202
});
203203

204204
test('multiple parent commits', function () {
205205
const GIT_OUTPUT_MULTIPLE_PARENTS = `52c293a05038d865604c2284aa8698bd087915a1
206206
john.doe@mail.com
207207
8e5a374372b8393906c7e380dbb09349c5385554 df27d8c75b129ab9b178b386077da2822101b217
208-
This is a commit message.`;
208+
This is a commit message.\x00`;
209209

210-
assert.deepEqual(parseGitCommit(GIT_OUTPUT_MULTIPLE_PARENTS), {
210+
assert.deepEqual(parseGitCommits(GIT_OUTPUT_MULTIPLE_PARENTS), [{
211211
hash: '52c293a05038d865604c2284aa8698bd087915a1',
212212
message: 'This is a commit message.',
213213
parents: ['8e5a374372b8393906c7e380dbb09349c5385554', 'df27d8c75b129ab9b178b386077da2822101b217'],
214214
authorEmail: 'john.doe@mail.com',
215-
});
215+
}]);
216216
});
217217

218218
test('no parent commits', function () {
219219
const GIT_OUTPUT_NO_PARENTS = `52c293a05038d865604c2284aa8698bd087915a1
220220
john.doe@mail.com
221221
222-
This is a commit message.`;
222+
This is a commit message.\x00`;
223223

224-
assert.deepEqual(parseGitCommit(GIT_OUTPUT_NO_PARENTS), {
224+
assert.deepEqual(parseGitCommits(GIT_OUTPUT_NO_PARENTS), [{
225225
hash: '52c293a05038d865604c2284aa8698bd087915a1',
226226
message: 'This is a commit message.',
227227
parents: [],
228228
authorEmail: 'john.doe@mail.com',
229-
});
229+
}]);
230230
});
231231
});
232232

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { CancellationToken, Disposable, TimelineItem, TimelineProvider, Uri, workspace, ThemeIcon } from 'vscode';
7+
import { Model } from './model';
8+
9+
export class GitTimelineProvider implements TimelineProvider {
10+
readonly source = 'git-history';
11+
readonly sourceDescription = 'Git History';
12+
13+
private _disposable: Disposable;
14+
15+
constructor(private readonly _model: Model) {
16+
this._disposable = workspace.registerTimelineProvider('*', this);
17+
}
18+
19+
dispose() {
20+
this._disposable.dispose();
21+
}
22+
23+
async provideTimeline(uri: Uri, _since: number, _token: CancellationToken): Promise<TimelineItem[]> {
24+
const repo = this._model.getRepository(uri);
25+
if (!repo) {
26+
return [];
27+
}
28+
29+
const commits = await repo.logFile(uri, { maxEntries: 10 });
30+
return commits.map<TimelineItem>(c => {
31+
let message = c.message;
32+
33+
const index = message.indexOf('\n');
34+
if (index !== -1) {
35+
message = `${message.substring(0, index)} \u2026`;
36+
}
37+
38+
return {
39+
id: c.hash,
40+
date: c.authorDate?.getTime() ?? 0,
41+
iconPath: new ThemeIcon('git-commit'),
42+
label: message,
43+
description: `${c.authorName} (${c.authorEmail}) \u2022 ${c.hash.substr(0, 8)}`,
44+
detail: `${c.authorName} (${c.authorEmail})\n${c.authorDate}\n\n${c.message}`,
45+
command: {
46+
title: 'Open Diff',
47+
command: 'git.openDiff',
48+
arguments: [uri, c.hash]
49+
}
50+
};
51+
});
52+
}
53+
}

0 commit comments

Comments
 (0)