Browse Source

Fixes many timeline issues

Events are better aggregated and bootstrapped
Resizing should be smoother and behave much better
Adds refresh on repository changes
Adds new empty message
main
Eric Amodio 2 years ago
parent
commit
130b7b997d
7 changed files with 552 additions and 322 deletions
  1. +139
    -59
      src/premium/webviews/timeline/timelineWebviewView.ts
  2. +22
    -46
      src/webviews/apps/premium/timeline/chart.scss
  3. +264
    -167
      src/webviews/apps/premium/timeline/chart.ts
  4. +27
    -14
      src/webviews/apps/premium/timeline/timeline.html
  5. +72
    -35
      src/webviews/apps/premium/timeline/timeline.scss
  6. +7
    -1
      src/webviews/apps/premium/timeline/timeline.ts
  7. +21
    -0
      src/webviews/apps/shared/utils.ts

+ 139
- 59
src/premium/webviews/timeline/timelineWebviewView.ts View File

@ -5,9 +5,10 @@ import { Commands } from '../../../constants';
import { Container } from '../../../container'; import { Container } from '../../../container';
import { PremiumFeatures } from '../../../features'; import { PremiumFeatures } from '../../../features';
import { GitUri } from '../../../git/gitUri'; import { GitUri } from '../../../git/gitUri';
import { RepositoryChange, RepositoryChangeComparisonMode, RepositoryChangeEvent } from '../../../git/models';
import { createFromDateDelta } from '../../../system/date'; import { createFromDateDelta } from '../../../system/date';
import { debug } from '../../../system/decorators/log'; import { debug } from '../../../system/decorators/log';
import { debounce } from '../../../system/function';
import { debounce, Deferrable } from '../../../system/function';
import { hasVisibleTextEditor, isTextEditor } from '../../../system/utils'; import { hasVisibleTextEditor, isTextEditor } from '../../../system/utils';
import { IpcMessage, onIpc } from '../../../webviews/protocol'; import { IpcMessage, onIpc } from '../../../webviews/protocol';
import { WebviewViewBase } from '../../../webviews/webviewViewBase'; import { WebviewViewBase } from '../../../webviews/webviewViewBase';
@ -16,72 +17,92 @@ import {
Commit, Commit,
DidChangeStateNotificationType, DidChangeStateNotificationType,
OpenDataPointCommandType, OpenDataPointCommandType,
Period,
State, State,
UpdatePeriodCommandType, UpdatePeriodCommandType,
} from './protocol'; } from './protocol';
interface Context {
editor: TextEditor | undefined;
period: Period | undefined;
etagRepository: number | undefined;
etagSubscription: number | undefined;
}
const defaultPeriod: Period = '3|M';
export class TimelineWebviewView extends WebviewViewBase<State> { export class TimelineWebviewView extends WebviewViewBase<State> {
private _editor: TextEditor | undefined;
private _period: `${number}|${'D' | 'M' | 'Y'}` = '3|M';
private _bootstraping = true;
/** The context the webview has */
private _context: Context;
/** The context the webview should have */
private _pendingContext: Partial<Context> | undefined;
constructor(container: Container) { constructor(container: Container) {
super(container, 'gitlens.views.timeline', 'timeline.html', 'Visual File History'); super(container, 'gitlens.views.timeline', 'timeline.html', 'Visual File History');
this.disposables.push(this.container.subscription.onDidChange(this.onSubscriptionChanged, this));
}
override dispose() {
super.dispose();
this._disposableVisibility?.dispose();
}
this._context = {
editor: undefined,
period: defaultPeriod,
etagRepository: 0,
etagSubscription: this.container.subscription.etag,
};
protected override onReady() {
this.onActiveEditorChanged(window.activeTextEditor);
this.disposables.push(
this.container.subscription.onDidChange(this.onSubscriptionChanged, this),
window.onDidChangeActiveTextEditor(this.onActiveEditorChanged, this),
this.container.git.onDidChangeRepository(this.onRepositoryChanged, this),
);
} }
protected override async includeBootstrap(): Promise<State> { protected override async includeBootstrap(): Promise<State> {
return this.getState(undefined);
this._bootstraping = true;
this.updatePendingEditor(window.activeTextEditor);
this._context = { ...this._context, ...this._pendingContext };
return this.getState(this._context);
} }
@debug({ args: false }) @debug({ args: false })
private onActiveEditorChanged(editor: TextEditor | undefined) { private onActiveEditorChanged(editor: TextEditor | undefined) {
if (!this.isReady || (this._editor === editor && editor != null)) return;
if (editor == null && hasVisibleTextEditor()) return;
if (editor != null && !isTextEditor(editor)) return;
if (!this.updatePendingEditor(editor)) return;
this._editor = editor;
void this.notifyDidChangeState(editor);
this.updateState();
} }
private onSubscriptionChanged(_e: SubscriptionChangeEvent) {
void this.notifyDidChangeState(this._editor);
private onRepositoryChanged(e: RepositoryChangeEvent) {
if (!e.changed(RepositoryChange.Heads, RepositoryChange.Index, RepositoryChangeComparisonMode.Any)) {
return;
}
if (this.updatePendingContext({ etagRepository: e.repository.etag })) {
this.updateState();
}
}
private onSubscriptionChanged(e: SubscriptionChangeEvent) {
if (this.updatePendingContext({ etagSubscription: e.etag })) {
this.updateState();
}
} }
private _disposableVisibility: Disposable | undefined;
protected override onVisibilityChanged(visible: boolean) { protected override onVisibilityChanged(visible: boolean) {
if (visible) {
if (this._disposableVisibility == null) {
this._disposableVisibility = window.onDidChangeActiveTextEditor(
debounce(this.onActiveEditorChanged, 500),
this,
);
}
this.onActiveEditorChanged(window.activeTextEditor);
} else {
this._disposableVisibility?.dispose();
this._disposableVisibility = undefined;
if (!visible) return;
this._editor = undefined;
// Since this gets called even the first time the webview is shown, avoid sending an update, because the bootstrap has the data
if (this._bootstraping) {
this._bootstraping = false;
return;
} }
this.updateState(true);
} }
protected override onMessageReceived(e: IpcMessage) { protected override onMessageReceived(e: IpcMessage) {
switch (e.method) { switch (e.method) {
case OpenDataPointCommandType.method: case OpenDataPointCommandType.method:
onIpc(OpenDataPointCommandType, e, params => { onIpc(OpenDataPointCommandType, e, params => {
if (params.data == null || this._editor == null || !params.data.selected) return;
if (params.data == null || !params.data.selected || this._context.editor == null) return;
const repository = this.container.git.getRepository(this._editor.document.uri);
const repository = this.container.git.getRepository(this._context.editor.document.uri);
if (repository == null) return; if (repository == null) return;
const commandArgs: ShowQuickCommitCommandArgs = { const commandArgs: ShowQuickCommitCommandArgs = {
@ -111,33 +132,35 @@ export class TimelineWebviewView extends WebviewViewBase {
case UpdatePeriodCommandType.method: case UpdatePeriodCommandType.method:
onIpc(UpdatePeriodCommandType, e, params => { onIpc(UpdatePeriodCommandType, e, params => {
this._period = params.period;
void this.notifyDidChangeState(this._editor);
if (this.updatePendingContext({ period: params.period })) {
this.updateState(true);
}
}); });
break; break;
} }
} }
@debug({ args: { 0: e => e?.document.uri.toString(true) } })
private async getState(editor: TextEditor | undefined): Promise<State> {
@debug({ args: false })
private async getState(current: Context): Promise<State> {
const access = await this.container.git.access(PremiumFeatures.Timeline); const access = await this.container.git.access(PremiumFeatures.Timeline);
const period = current.period ?? defaultPeriod;
const dateFormat = this.container.config.defaultDateFormat ?? 'MMMM Do, YYYY h:mma'; const dateFormat = this.container.config.defaultDateFormat ?? 'MMMM Do, YYYY h:mma';
if (editor == null || !access.allowed) {
if (current.editor == null || !access.allowed) {
return { return {
period: this._period,
period: period,
title: 'There are no editors open that can provide file history information', title: 'There are no editors open that can provide file history information',
dateFormat: dateFormat, dateFormat: dateFormat,
access: access, access: access,
}; };
} }
const gitUri = await GitUri.fromUri(editor.document.uri);
const gitUri = await GitUri.fromUri(current.editor.document.uri);
const repoPath = gitUri.repoPath!; const repoPath = gitUri.repoPath!;
const title = gitUri.relativePath; const title = gitUri.relativePath;
// this.setTitle(`${this.title} \u2022 ${gitUri.fileName}`);
this.description = gitUri.fileName; this.description = gitUri.fileName;
const [currentUser, log] = await Promise.all([ const [currentUser, log] = await Promise.all([
@ -145,16 +168,16 @@ export class TimelineWebviewView extends WebviewViewBase {
this.container.git.getLogForFile(repoPath, gitUri.fsPath, { this.container.git.getLogForFile(repoPath, gitUri.fsPath, {
limit: 0, limit: 0,
ref: gitUri.sha, ref: gitUri.sha,
since: this.getPeriodDate().toISOString(),
since: this.getPeriodDate(period).toISOString(),
}), }),
]); ]);
if (log == null) { if (log == null) {
return { return {
dataset: [], dataset: [],
period: this._period,
title: title,
uri: editor.document.uri.toString(),
period: period,
title: 'No commits found for the specified time period',
uri: current.editor.document.uri.toString(),
dateFormat: dateFormat, dateFormat: dateFormat,
access: access, access: access,
}; };
@ -181,17 +204,15 @@ export class TimelineWebviewView extends WebviewViewBase {
return { return {
dataset: dataset, dataset: dataset,
period: this._period,
period: period,
title: title, title: title,
uri: editor.document.uri.toString(),
uri: current.editor.document.uri.toString(),
dateFormat: dateFormat, dateFormat: dateFormat,
access: access, access: access,
}; };
} }
private getPeriodDate(): Date {
const period = this._period ?? '3|M';
private getPeriodDate(period: Period): Date {
const [number, unit] = period.split('|'); const [number, unit] = period.split('|');
switch (unit) { switch (unit) {
@ -206,13 +227,72 @@ export class TimelineWebviewView extends WebviewViewBase {
} }
} }
private async notifyDidChangeState(editor: TextEditor | undefined) {
if (!this.isReady) return false;
private updatePendingContext(context: Partial<Context>): boolean {
let changed = false;
for (const [key, value] of Object.entries(context)) {
if ((this._context as unknown as Record<string, unknown>)[key] !== value) {
if (this._pendingContext == null) {
this._pendingContext = {};
}
return window.withProgress({ location: { viewId: this.id } }, async () =>
this.notify(DidChangeStateNotificationType, {
state: await this.getState(editor),
}),
);
(this._pendingContext as Record<string, unknown>)[key] = value;
changed = true;
}
}
return changed;
}
private updatePendingEditor(editor: TextEditor | undefined): boolean {
if (editor == null && hasVisibleTextEditor()) return false;
if (editor != null && !isTextEditor(editor)) return false;
let etag;
if (editor != null) {
const repository = this.container.git.getRepository(editor.document.uri);
etag = repository?.etag ?? 0;
} else {
etag = 0;
}
return this.updatePendingContext({ editor: editor, etagRepository: etag });
}
private _notifyDidChangeStateDebounced: Deferrable<() => void> | undefined = undefined;
@debug()
private updateState(immediate: boolean = false) {
if (!this.isReady || !this.visible) return;
this.updatePendingEditor(window.activeTextEditor);
if (immediate) {
void this.notifyDidChangeState();
return;
}
if (this._notifyDidChangeStateDebounced == null) {
this._notifyDidChangeStateDebounced = debounce(this.notifyDidChangeState.bind(this), 500);
}
this._notifyDidChangeStateDebounced();
}
@debug()
private async notifyDidChangeState() {
if (!this.isReady || !this.visible) return false;
this._notifyDidChangeStateDebounced?.cancel();
const context = { ...this._context, ...this._pendingContext };
return window.withProgress({ location: { viewId: this.id } }, async () => {
const success = await this.notify(DidChangeStateNotificationType, {
state: await this.getState(context),
});
if (success) {
this._context = context;
this._pendingContext = undefined;
}
});
} }
} }

+ 22
- 46
src/webviews/apps/premium/timeline/chart.scss View File

@ -94,18 +94,18 @@
line { line {
.vscode-dark & { .vscode-dark & {
stroke: var(--color-background--lighten-075);
stroke: var(--color-background--lighten-05);
&.bb-ygrid { &.bb-ygrid {
stroke: var(--color-background--lighten-15);
stroke: var(--color-background--lighten-05);
} }
} }
.vscode-light & { .vscode-light & {
stroke: var(--color-background--darken-075);
stroke: var(--color-background--darken-05);
&.bb-ygrid { &.bb-ygrid {
stroke: var(--color-background--darken-15);
stroke: var(--color-background--darken-05);
} }
} }
} }
@ -116,13 +116,13 @@
} }
} }
.bb-xgrid {
stroke-dasharray: 2 2;
}
// .bb-xgrid {
// stroke-dasharray: 2 2;
// }
.bb-ygrid {
stroke-dasharray: 2 1;
}
// .bb-ygrid {
// stroke-dasharray: 2 1;
// }
.bb-xgrid-focus { .bb-xgrid-focus {
line { line {
@ -148,43 +148,21 @@
/*-- Point --*/ /*-- Point --*/
.bb-circle { .bb-circle {
// opacity: 0.8 !important;
// stroke-width: 0;
// fill-opacity: 0.6;
&._expanded_ { &._expanded_ {
// stroke-width: 1px;
// stroke: white;
// opacity: 1 !important;
fill-opacity: 1;
opacity: 1 !important;
fill-opacity: 1 !important;
stroke-opacity: 1 !important;
stroke-width: 3px; stroke-width: 3px;
stroke-opacity: 0.6;
} }
} }
.bb-selected-circle { .bb-selected-circle {
// fill: white;
// stroke-width: 2px;
// opacity: 1 !important;
fill-opacity: 1;
opacity: 1 !important;
fill-opacity: 1 !important;
stroke-opacity: 1 !important;
stroke-width: 3px; stroke-width: 3px;
stroke-opacity: 0.6;
} }
// .bb-circles {
// &.bb-focused {
// opacity: 1;
// }
// &.bb-defocused {
// opacity: 0.3 !important;
// }
// }
// rect.bb-circle, // rect.bb-circle,
// use.bb-circle { // use.bb-circle {
// &._expanded_ { // &._expanded_ {
@ -207,15 +185,12 @@
/*-- Bar --*/ /*-- Bar --*/
.bb-bar { .bb-bar {
stroke-width: 0; stroke-width: 0;
// opacity: 0.8 !important;
// fill-opacity: 0.6;
opacity: 0.9 !important;
fill-opacity: 0.9 !important;
&._expanded_ { &._expanded_ {
fill-opacity: 0.75;
// opacity: 1 !important;
// stroke-width: 3px;
// stroke-opacity: 0.6;
opacity: 1 !important;
fill-opacity: 1 !important;
} }
} }
@ -243,7 +218,7 @@
.bb-target.bb-defocused, .bb-target.bb-defocused,
.bb-circles.bb-defocused { .bb-circles.bb-defocused {
opacity: 0.3 !important;
opacity: 0.2 !important;
} }
.bb-target.bb-defocused .text-overlapping, .bb-target.bb-defocused .text-overlapping,
.bb-circles.bb-defocused .text-overlapping { .bb-circles.bb-defocused .text-overlapping {
@ -330,6 +305,7 @@
&:not(.bb-tooltip-name-additions, .bb-tooltip-name-deletions) { &:not(.bb-tooltip-name-additions, .bb-tooltip-name-deletions) {
display: flex; display: flex;
flex-direction: column-reverse; flex-direction: column-reverse;
margin-bottom: -1px;
td { td {
&.name { &.name {

+ 264
- 167
src/webviews/apps/premium/timeline/chart.ts View File

@ -1,10 +1,12 @@
'use strict'; 'use strict';
/*global*/ /*global*/
import { bar, bb, bubble, Chart, ChartOptions, DataItem, selection, zoom } from 'billboard.js';
import { bar, bb, bubble, Chart, ChartOptions, ChartTypes, DataItem, zoom } from 'billboard.js';
// import BubbleCompare from 'billboard.js/dist/plugin/billboardjs-plugin-bubblecompare'; // import BubbleCompare from 'billboard.js/dist/plugin/billboardjs-plugin-bubblecompare';
// import { scaleSqrt } from 'd3-scale';
import { Commit, State } from '../../../../premium/webviews/timeline/protocol'; import { Commit, State } from '../../../../premium/webviews/timeline/protocol';
import { formatDate, fromNow } from '../../shared/date'; import { formatDate, fromNow } from '../../shared/date';
import { Emitter, Event } from '../../shared/events'; import { Emitter, Event } from '../../shared/events';
import { throttle } from '../../shared/utils';
export interface DataPointClickEvent { export interface DataPointClickEvent {
data: { data: {
@ -19,27 +21,244 @@ export class TimelineChart {
return this._onDidClickDataPoint.event; return this._onDidClickDataPoint.event;
} }
private readonly _chart: Chart;
private _commitsByTimestamp = new Map<string, Commit>();
private _authorsByIndex = new Map<number, string>();
private _indexByAuthors = new Map<string, number>();
private readonly $container: HTMLElement;
private _chart: Chart | undefined;
private _chartDimensions: { height: number; width: number };
private readonly _resizeObserver: ResizeObserver;
private readonly _selector: string;
private readonly _commitsByTimestamp = new Map<string, Commit>();
private readonly _authorsByIndex = new Map<number, string>();
private readonly _indexByAuthors = new Map<string, number>();
private _dateFormat: string = undefined!; private _dateFormat: string = undefined!;
constructor(selector: string) { constructor(selector: string) {
this._selector = selector;
const fn = throttle(() => {
const dimensions = this._chartDimensions;
this._chart?.resize({
width: dimensions.width,
height: dimensions.height - 10,
});
}, 100);
this._resizeObserver = new ResizeObserver(entries => {
const size = entries[0].borderBoxSize[0];
const dimensions = {
width: Math.floor(size.inlineSize),
height: Math.floor(size.blockSize),
};
if (
this._chartDimensions.height === dimensions.height &&
this._chartDimensions.width === dimensions.width
) {
return;
}
this._chartDimensions = dimensions;
fn();
});
this.$container = document.querySelector(selector)!.parentElement!;
const rect = this.$container.getBoundingClientRect();
this._chartDimensions = { height: Math.floor(rect.height), width: Math.floor(rect.width) };
this._resizeObserver.observe(this.$container);
}
reset() {
this._chart?.unselect();
this._chart?.unzoom();
}
updateChart(state: State) {
this._dateFormat = state.dateFormat;
this._commitsByTimestamp.clear();
this._authorsByIndex.clear();
this._indexByAuthors.clear();
if (state?.dataset == null || state.dataset.length === 0) {
this._chart?.destroy();
this._chart = undefined;
const $overlay = document.getElementById('chart-empty-overlay') as HTMLDivElement;
$overlay?.classList.toggle('hidden', false);
const $emptyMessage = $overlay.querySelector('[data-bind="empty"]') as HTMLHeadingElement;
$emptyMessage.textContent = state.title;
return;
}
const $overlay = document.getElementById('chart-empty-overlay') as HTMLDivElement;
$overlay?.classList.toggle('hidden', true);
const xs: { [key: string]: string } = {};
const colors: { [key: string]: string } = {};
const names: { [key: string]: string } = {};
const axes: { [key: string]: string } = {};
const types: { [key: string]: ChartTypes } = {};
const groups: string[][] = [];
const series: { [key: string]: any } = {};
const group = [];
let index = 0;
let commit: Commit;
let author: string;
let date: string;
let additions: number;
let deletions: number;
// // Get the min and max additions and deletions from the dataset
// let minChanges = Infinity;
// let maxChanges = -Infinity;
// for (const commit of state.dataset) {
// const changes = commit.additions + commit.deletions;
// if (changes < minChanges) {
// minChanges = changes;
// }
// if (changes > maxChanges) {
// maxChanges = changes;
// }
// }
// const bubbleScale = scaleSqrt([minChanges, maxChanges], [6, 100]);
for (commit of state.dataset) {
({ author, date, additions, deletions } = commit);
if (!this._indexByAuthors.has(author)) {
this._indexByAuthors.set(author, index);
this._authorsByIndex.set(index, author);
index--;
}
const x = 'time';
if (series[x] == null) {
series[x] = [];
series['additions'] = [];
series['deletions'] = [];
xs['additions'] = x;
xs['deletions'] = x;
axes['additions'] = 'y2';
axes['deletions'] = 'y2';
names['additions'] = 'Additions';
names['deletions'] = 'Deletions';
colors['additions'] = 'rgba(73, 190, 71, 1)';
colors['deletions'] = 'rgba(195, 32, 45, 1)';
types['additions'] = bar();
types['deletions'] = bar();
group.push(x);
groups.push(['additions', 'deletions']);
}
const authorX = `${x}.${author}`;
if (series[authorX] == null) {
series[authorX] = [];
series[author] = [];
xs[author] = authorX;
axes[author] = 'y';
names[author] = author;
types[author] = bubble();
group.push(author, authorX);
}
series[x].push(date);
series['additions'].push(additions);
series['deletions'].push(deletions);
series[authorX].push(date);
const z = additions + deletions; //bubbleScale(additions + deletions);
series[author].push({
y: this._indexByAuthors.get(author),
z: z,
});
this._commitsByTimestamp.set(date, commit);
}
groups.push(group);
const columns = Object.entries(series).map(([key, value]) => [key, ...value]);
if (this._chart == null) {
const options = this.getChartOptions();
if (options.axis == null) {
options.axis = { y: { tick: {} } };
}
if (options.axis.y == null) {
options.axis.y = { tick: {} };
}
if (options.axis.y.tick == null) {
options.axis.y.tick = {};
}
options.axis.y.min = index - 2;
options.axis.y.tick.values = [...this._authorsByIndex.keys()];
options.data = {
...options.data,
axes: axes,
colors: colors,
columns: columns,
groups: groups,
names: names,
types: types,
xs: xs,
};
this._chart = bb.generate(options);
} else {
this._chart.config('axis.y.tick.values', [...this._authorsByIndex.keys()], false);
this._chart.config('axis.y.min', index - 2, false);
this._chart.groups(groups);
this._chart.load({
axes: axes,
colors: colors,
columns: columns,
names: names,
types: types,
xs: xs,
unload: true,
});
}
}
private getChartOptions() {
const config: ChartOptions = { const config: ChartOptions = {
bindto: selector,
bindto: this._selector,
data: { data: {
columns: [], columns: [],
types: { time: bubble(), additions: bar(), deletions: bar() }, types: { time: bubble(), additions: bar(), deletions: bar() },
xFormat: '%Y-%m-%dT%H:%M:%S.%LZ', xFormat: '%Y-%m-%dT%H:%M:%S.%LZ',
xLocaltime: false, xLocaltime: false,
selection: {
enabled: selection(),
draggable: false,
grouped: false,
multiple: false,
},
// selection: {
// enabled: selection(),
// draggable: false,
// grouped: false,
// multiple: false,
// },
onclick: this.onDataPointClicked.bind(this), onclick: this.onDataPointClicked.bind(this),
}, },
axis: { axis: {
@ -61,7 +280,7 @@ export class TimelineChart {
y: { y: {
max: 0, max: 0,
padding: { padding: {
top: 50,
top: 75,
bottom: 100, bottom: 100,
}, },
show: true, show: true,
@ -72,16 +291,16 @@ export class TimelineChart {
}, },
y2: { y2: {
label: { label: {
text: 'Number of Lines Changed',
text: 'Lines changed',
position: 'outer-middle', position: 'outer-middle',
}, },
// min: 0, // min: 0,
show: true, show: true,
tick: {
outer: true,
// culling: true,
// stepSize: 1,
},
// tick: {
// outer: true,
// // culling: true,
// // stepSize: 1,
// },
}, },
}, },
bar: { bar: {
@ -90,7 +309,8 @@ export class TimelineChart {
padding: 2, padding: 2,
}, },
bubble: { bubble: {
maxR: 50,
maxR: 100,
zerobased: true,
}, },
grid: { grid: {
focus: { focus: {
@ -98,7 +318,7 @@ export class TimelineChart {
show: true, show: true,
y: true, y: true,
}, },
front: true,
front: false,
lines: { lines: {
front: false, front: false,
}, },
@ -113,20 +333,12 @@ export class TimelineChart {
show: true, show: true,
padding: 10, padding: 10,
}, },
// point: {
// r: 6,
// focus: {
// expand: {
// enabled: true,
// r: 9,
// },
// },
// select: {
// r: 12,
// },
// },
resize: { resize: {
auto: true,
auto: false,
},
size: {
height: this._chartDimensions.height - 10,
width: this._chartDimensions.width,
}, },
tooltip: { tooltip: {
grouped: true, grouped: true,
@ -160,148 +372,20 @@ export class TimelineChart {
}, },
// plugins: [ // plugins: [
// new BubbleCompare({ // new BubbleCompare({
// minR: 3,
// maxR: 30,
// minR: 6,
// maxR: 100,
// expandScale: 1.2, // expandScale: 1.2,
// }), // }),
// ], // ],
}; };
this._chart = bb.generate(config);
}
private onDataPointClicked(d: DataItem, _element: SVGElement) {
const commit = this._commitsByTimestamp.get(new Date(d.x).toISOString());
if (commit == null) return;
const selected = this._chart.selected(d.id) as unknown as DataItem[];
this._onDidClickDataPoint.fire({
data: {
id: commit.commit,
selected: selected?.[0]?.id === d.id,
},
});
}
reset() {
this._chart.unselect();
this._chart.unzoom();
}
updateChart(state: State) {
this._dateFormat = state.dateFormat;
this._commitsByTimestamp.clear();
this._authorsByIndex.clear();
this._indexByAuthors.clear();
if (state?.dataset == null) {
this._chart.unload();
return;
}
const xs: { [key: string]: string } = {};
const colors: { [key: string]: string } = {};
const names: { [key: string]: string } = {};
const axes: { [key: string]: string } = {};
const types: { [key: string]: string } = {};
const groups: string[][] = [];
const series: { [key: string]: any } = {};
const group = [];
let index = 0;
let commit: Commit;
let author: string;
let date: string;
let additions: number;
let deletions: number;
for (commit of state.dataset) {
({ author, date, additions, deletions } = commit);
if (!this._indexByAuthors.has(author)) {
this._indexByAuthors.set(author, index);
this._authorsByIndex.set(index, author);
index--;
}
const x = 'time';
if (series[x] == null) {
series[x] = [];
series['additions'] = [];
series['deletions'] = [];
xs['additions'] = x;
xs['deletions'] = x;
axes['additions'] = 'y2';
axes['deletions'] = 'y2';
names['additions'] = 'Additions';
names['deletions'] = 'Deletions';
colors['additions'] = 'rgba(73, 190, 71, 1)';
colors['deletions'] = 'rgba(195, 32, 45, 1)';
types['additions'] = bar();
types['deletions'] = bar();
group.push(x);
groups.push(['additions', 'deletions']);
}
const authorX = `${x}.${author}`;
if (series[authorX] == null) {
series[authorX] = [];
series[author] = [];
xs[author] = authorX;
axes[author] = 'y';
names[author] = author;
types[author] = bubble();
group.push(author, authorX);
}
series[x].push(date);
series['additions'].push(additions);
series['deletions'].push(deletions);
series[authorX].push(date);
series[author].push({ /*x: date,*/ y: this._indexByAuthors.get(author), z: additions + deletions });
this._commitsByTimestamp.set(date, commit);
}
this._chart.config('axis.y.tick.values', [...this._authorsByIndex.keys()], false);
this._chart.config('axis.y.min', index - 2, false);
groups.push(group);
this._chart.groups(groups);
const columns = Object.entries(series).map(([key, value]) => [key, ...value]);
this._chart.load({
columns: columns,
xs: xs,
axes: axes,
names: names,
colors: colors,
types: types,
unload: true,
});
return config;
} }
private getTooltipName(name: string, ratio: number, id: string, index: number) { private getTooltipName(name: string, ratio: number, id: string, index: number) {
if (id === 'additions' || /*id === 'changes' ||*/ id === 'deletions') return name; if (id === 'additions' || /*id === 'changes' ||*/ id === 'deletions') return name;
const date = new Date(this._chart.data(id)[0].values[index].x);
const date = new Date(this._chart!.data(id)[0].values[index].x);
const commit = this._commitsByTimestamp.get(date.toISOString()); const commit = this._commitsByTimestamp.get(date.toISOString());
return commit?.commit.slice(0, 8) ?? '00000000'; return commit?.commit.slice(0, 8) ?? '00000000';
} }
@ -320,10 +404,23 @@ export class TimelineChart {
return value === 0 ? undefined! : value; return value === 0 ? undefined! : value;
} }
const date = new Date(this._chart.data(id)[0].values[index].x);
const date = new Date(this._chart!.data(id)[0].values[index].x);
const commit = this._commitsByTimestamp.get(date.toISOString()); const commit = this._commitsByTimestamp.get(date.toISOString());
return commit?.message ?? '???'; return commit?.message ?? '???';
} }
private onDataPointClicked(d: DataItem, _element: SVGElement) {
const commit = this._commitsByTimestamp.get(new Date(d.x).toISOString());
if (commit == null) return;
// const selected = this._chart!.selected(d.id) as unknown as DataItem[];
this._onDidClickDataPoint.fire({
data: {
id: commit.commit,
selected: true, //selected?.[0]?.id === d.id,
},
});
}
} }
function capitalize(s: string): string { function capitalize(s: string): string {

+ 27
- 14
src/webviews/apps/premium/timeline/timeline.html View File

@ -2,33 +2,46 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<style>
.hidden {
display: none !important;
}
.preload .header {
visibility: hidden;
}
</style>
</head> </head>
<body class="preload"> <body class="preload">
<div class="container"> <div class="container">
<section id="header">
<section class="header">
<h2 data-bind="title"></h2> <h2 data-bind="title"></h2>
<h2 data-bind="description"></h2> <h2 data-bind="description"></h2>
<div class="select-container">
<label for="periods">Since</label>
<vscode-dropdown id="periods" name="periods" position="below">
<vscode-option value="7|D">1 week ago</vscode-option>
<vscode-option value="1|M">1 month ago</vscode-option>
<vscode-option value="3|M" selected="true">3 months ago</vscode-option>
<vscode-option value="6|M">6 months ago</vscode-option>
<vscode-option value="9|M">9 months ago</vscode-option>
<vscode-option value="1|Y">1 year ago</vscode-option>
<vscode-option value="2|Y">2 years ago</vscode-option>
<vscode-option value="4|Y">4 years ago</vscode-option>
</vscode-dropdown>
<div class="toolbox">
<div class="select-container">
<label for="periods">Timeframe</label>
<vscode-dropdown id="periods" name="periods" position="below">
<vscode-option value="7|D">1 week</vscode-option>
<vscode-option value="1|M">1 month</vscode-option>
<vscode-option value="3|M" selected="true">3 months</vscode-option>
<vscode-option value="6|M">6 months</vscode-option>
<vscode-option value="9|M">9 months</vscode-option>
<vscode-option value="1|Y">1 year</vscode-option>
<vscode-option value="2|Y">2 years</vscode-option>
<vscode-option value="4|Y">4 years</vscode-option>
</vscode-dropdown>
</div>
</div> </div>
</section> </section>
<section id="content"> <section id="content">
<div id="chart"></div> <div id="chart"></div>
<div id="chart-empty-overlay" class="hidden">
<h1 data-bind="empty"></h1>
</div>
</section> </section>
<section id="footer"></section> <section id="footer"></section>
</div> </div>
<div id="overlay">
<div id="overlay" class="hidden">
<p> <p>
The The
<a href="command:gitlens.openWalkthrough?%22gitlens.premium%7Cgitlens.premium.visualFileHistory%22" <a href="command:gitlens.openWalkthrough?%22gitlens.premium%7Cgitlens.premium.visualFileHistory%22"

+ 72
- 35
src/webviews/apps/premium/timeline/timeline.scss View File

@ -17,6 +17,9 @@ body {
overflow: hidden; overflow: hidden;
margin: 0 20px 20px 20px; margin: 0 20px 20px 20px;
padding: 0; padding: 0;
min-width: 400px;
overflow-x: scroll;
} }
.container { .container {
@ -24,6 +27,7 @@ body {
grid-template-rows: min-content 1fr min-content; grid-template-rows: min-content 1fr min-content;
min-height: 100%; min-height: 100%;
// width: 100%; // width: 100%;
overflow: hidden;
} }
section { section {
@ -34,18 +38,6 @@ section {
h2 { h2 {
font-weight: 400; font-weight: 400;
&[data-bind='title'] {
flex: auto 0 1;
}
&[data-bind='description'] {
flex: auto 1 1;
font-size: 1.3em;
font-weight: 200;
margin-left: 1.5rem;
opacity: 0.7;
}
} }
h3 { h3 {
@ -83,6 +75,19 @@ p {
margin-bottom: 0; margin-bottom: 0;
} }
.select-container {
display: flex;
align-items: center;
justify-content: flex-end;
// margin: 1em;
flex: 100% 0 1;
label {
margin: 0 1em;
font-size: var(--font-size);
}
}
vscode-button { vscode-button {
align-self: center; align-self: center;
margin-top: 1.5rem; margin-top: 1.5rem;
@ -104,38 +109,70 @@ span.button-subaction {
} }
} }
section#header {
display: flex;
flex-direction: row;
.header {
display: grid;
grid-template-columns: max-content minmax(min-content, 1fr) max-content;
align-items: baseline; align-items: baseline;
grid-template-areas: 'title description toolbox';
justify-content: start;
margin-bottom: 1rem;
@media all and (max-width: 500px) { @media all and (max-width: 500px) {
flex-wrap: wrap;
grid-template-areas:
'title description'
'empty toolbox';
grid-template-columns: max-content minmax(min-content, 1fr);
} }
}
.select-container {
display: flex;
align-items: center;
justify-content: flex-end;
margin: 1em;
flex: 100% 0 1;
h2[data-bind='title'] {
grid-area: title;
margin-bottom: 0;
}
label {
margin-right: 1em;
font-size: var(--font-size);
h2[data-bind='description'] {
grid-area: description;
font-size: 1.3em;
font-weight: 200;
margin-left: 1.5rem;
opacity: 0.7;
overflow-wrap: anywhere;
}
.toolbox {
grid-area: toolbox;
} }
} }
// .chart-container {
// height: calc(100% - 0.5em);
// width: 100%;
// overflow: hidden;
// }
#content {
position: relative;
overflow: hidden;
width: 100%;
// height: calc(100% - 1rem);
// height: 100%;
}
#chart { #chart {
height: 100%; height: 100%;
width: 100%; width: 100%;
overflow: hidden;
}
#chart-empty-overlay {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: var(--color-background);
h1 {
font-weight: 600;
padding-bottom: 10%;
}
} }
[data-visible] { [data-visible] {
@ -154,12 +191,12 @@ section#header {
min-height: 100%; min-height: 100%;
padding: 0 2rem 2rem 2rem; padding: 0 2rem 2rem 2rem;
display: none;
display: grid;
grid-template-rows: min-content; grid-template-rows: min-content;
}
&.subscription-required {
display: grid;
}
.hidden {
display: none !important;
} }
@import './chart'; @import './chart';

+ 7
- 1
src/webviews/apps/premium/timeline/timeline.ts View File

@ -93,7 +93,7 @@ export class TimelineApp extends App {
private updateState(): void { private updateState(): void {
const $overlay = document.getElementById('overlay') as HTMLDivElement; const $overlay = document.getElementById('overlay') as HTMLDivElement;
$overlay.classList.toggle('subscription-required', !this.state.access.allowed);
$overlay.classList.toggle('hidden', this.state.access.allowed);
const $slot = document.getElementById('overlay-slot') as HTMLDivElement; const $slot = document.getElementById('overlay-slot') as HTMLDivElement;
@ -131,6 +131,12 @@ export class TimelineApp extends App {
} }
let { title } = this.state; let { title } = this.state;
const empty = this.state.dataset == null || this.state.dataset.length === 0;
if (empty) {
title = '';
}
let description = ''; let description = '';
const index = title.lastIndexOf('/'); const index = title.lastIndexOf('/');
if (index >= 0) { if (index >= 0) {

+ 21
- 0
src/webviews/apps/shared/utils.ts View File

@ -0,0 +1,21 @@
export function throttle(callback: (this: any, ...args: any[]) => any, limit: number) {
let waiting = false;
let pending = false;
return function (this: any, ...args: any[]) {
if (waiting) {
pending = true;
return;
}
callback.apply(this, args);
waiting = true;
setTimeout(() => {
waiting = false;
if (pending) {
callback.apply(this, args);
pending = false;
}
}, limit);
};
}

Loading…
Cancel
Save