@ -0,0 +1,44 @@ | |||||
import type { FeatureAccess } from '../../../features'; | |||||
import { IpcCommandType, IpcNotificationType } from '../../../webviews/protocol'; | |||||
export interface State { | |||||
dataset?: Commit[]; | |||||
period: Period; | |||||
title: string; | |||||
uri?: string; | |||||
dateFormat: string; | |||||
access: FeatureAccess; | |||||
} | |||||
export interface Commit { | |||||
commit: string; | |||||
author: string; | |||||
date: string; | |||||
message: string; | |||||
additions: number; | |||||
deletions: number; | |||||
sort: number; | |||||
} | |||||
export type Period = `${number}|${'D' | 'M' | 'Y'}`; | |||||
export interface DidChangeStateParams { | |||||
state: State; | |||||
} | |||||
export const DidChangeStateNotificationType = new IpcNotificationType<DidChangeStateParams>('timeline/data/didChange'); | |||||
export interface OpenDataPointParams { | |||||
data?: { | |||||
id: string; | |||||
selected: boolean; | |||||
}; | |||||
} | |||||
export const OpenDataPointCommandType = new IpcCommandType<OpenDataPointParams>('timeline/point/click'); | |||||
export interface UpdatePeriodParams { | |||||
period: Period; | |||||
} | |||||
export const UpdatePeriodCommandType = new IpcCommandType<UpdatePeriodParams>('timeline/period/update'); |
@ -0,0 +1,205 @@ | |||||
'use strict'; | |||||
import { commands, ProgressLocation, TextEditor, window } from 'vscode'; | |||||
import { ShowQuickCommitCommandArgs } from '../../../commands'; | |||||
import { Commands } from '../../../constants'; | |||||
import type { Container } from '../../../container'; | |||||
import { PremiumFeatures } from '../../../features'; | |||||
import { GitUri } from '../../../git/gitUri'; | |||||
import { createFromDateDelta } from '../../../system/date'; | |||||
import { debug } from '../../../system/decorators/log'; | |||||
import { debounce } from '../../../system/function'; | |||||
import { hasVisibleTextEditor, isTextEditor } from '../../../system/utils'; | |||||
import { IpcMessage, onIpc } from '../../../webviews/protocol'; | |||||
import { WebviewBase } from '../../../webviews/webviewBase'; | |||||
import { SubscriptionChangeEvent } from '../../subscription/subscriptionService'; | |||||
import { | |||||
Commit, | |||||
DidChangeStateNotificationType, | |||||
OpenDataPointCommandType, | |||||
State, | |||||
UpdatePeriodCommandType, | |||||
} from './protocol'; | |||||
export class TimelineWebview extends WebviewBase<State> { | |||||
private _editor: TextEditor | undefined; | |||||
private _period: `${number}|${'D' | 'M' | 'Y'}` = '3|M'; | |||||
constructor(container: Container) { | |||||
super( | |||||
container, | |||||
'gitlens.timeline', | |||||
'timeline.html', | |||||
'images/gitlens-icon.png', | |||||
'Visual File History', | |||||
Commands.ShowTimelinePage, | |||||
); | |||||
this.disposables.push( | |||||
this.container.subscription.onDidChange(this.onSubscriptionChanged, this), | |||||
window.onDidChangeActiveTextEditor(debounce(this.onActiveEditorChanged, 500), this), | |||||
); | |||||
} | |||||
protected override onReady() { | |||||
this.onActiveEditorChanged(window.activeTextEditor); | |||||
} | |||||
protected override async includeBootstrap(): Promise<State> { | |||||
return this.getState(undefined); | |||||
} | |||||
@debug({ args: false }) | |||||
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; | |||||
this._editor = editor; | |||||
void this.notifyDidChangeState(editor); | |||||
} | |||||
private onSubscriptionChanged(_e: SubscriptionChangeEvent) { | |||||
void this.notifyDidChangeState(this._editor); | |||||
} | |||||
protected override onMessageReceived(e: IpcMessage) { | |||||
switch (e.method) { | |||||
case OpenDataPointCommandType.method: | |||||
onIpc(OpenDataPointCommandType, e, params => { | |||||
if (params.data == null || this._editor == null || !params.data.selected) return; | |||||
const repository = this.container.git.getRepository(this._editor.document.uri); | |||||
if (repository == null) return; | |||||
const commandArgs: ShowQuickCommitCommandArgs = { | |||||
repoPath: repository.path, | |||||
sha: params.data.id, | |||||
}; | |||||
void commands.executeCommand(Commands.ShowQuickCommit, commandArgs); | |||||
// const commandArgs: DiffWithPreviousCommandArgs = { | |||||
// line: 0, | |||||
// showOptions: { | |||||
// preserveFocus: true, | |||||
// preview: true, | |||||
// viewColumn: ViewColumn.Beside, | |||||
// }, | |||||
// }; | |||||
// void commands.executeCommand( | |||||
// Commands.DiffWithPrevious, | |||||
// new GitUri(gitUri, { repoPath: gitUri.repoPath!, sha: params.data.id }), | |||||
// commandArgs, | |||||
// ); | |||||
}); | |||||
break; | |||||
case UpdatePeriodCommandType.method: | |||||
onIpc(UpdatePeriodCommandType, e, params => { | |||||
this._period = params.period; | |||||
void this.notifyDidChangeState(this._editor); | |||||
}); | |||||
break; | |||||
} | |||||
} | |||||
@debug({ args: { 0: e => e?.document.uri.toString(true) } }) | |||||
private async getState(editor: TextEditor | undefined): Promise<State> { | |||||
const access = await this.container.git.access(PremiumFeatures.Timeline); | |||||
const dateFormat = this.container.config.defaultDateFormat ?? 'MMMM Do, YYYY h:mma'; | |||||
if (editor == null || !access.allowed) { | |||||
return { | |||||
period: this._period, | |||||
title: 'There are no editors open that can provide file history information', | |||||
dateFormat: dateFormat, | |||||
access: access, | |||||
}; | |||||
} | |||||
const gitUri = await GitUri.fromUri(editor.document.uri); | |||||
const repoPath = gitUri.repoPath!; | |||||
const title = gitUri.relativePath; | |||||
// this.setTitle(`${this.title} \u2022 ${gitUri.fileName}`); | |||||
// this.description = gitUri.fileName; | |||||
const [currentUser, log] = await Promise.all([ | |||||
this.container.git.getCurrentUser(repoPath), | |||||
this.container.git.getLogForFile(repoPath, gitUri.fsPath, { | |||||
limit: 0, | |||||
ref: gitUri.sha, | |||||
since: this.getPeriodDate().toISOString(), | |||||
}), | |||||
]); | |||||
if (log == null) { | |||||
return { | |||||
dataset: [], | |||||
period: this._period, | |||||
title: title, | |||||
uri: editor.document.uri.toString(), | |||||
dateFormat: dateFormat, | |||||
access: access, | |||||
}; | |||||
} | |||||
const name = currentUser?.name ? `${currentUser.name} (you)` : 'You'; | |||||
const dataset: Commit[] = []; | |||||
for (const commit of log.commits.values()) { | |||||
const stats = commit.file?.stats; | |||||
dataset.push({ | |||||
author: commit.author.name === 'You' ? name : commit.author.name, | |||||
additions: stats?.additions ?? 0, | |||||
// changed: stats?.changes ?? 0, | |||||
deletions: stats?.deletions ?? 0, | |||||
commit: commit.sha, | |||||
date: commit.date.toISOString(), | |||||
message: commit.message ?? commit.summary, | |||||
sort: commit.date.getTime(), | |||||
}); | |||||
} | |||||
dataset.sort((a, b) => b.sort - a.sort); | |||||
return { | |||||
dataset: dataset, | |||||
period: this._period, | |||||
title: title, | |||||
uri: editor.document.uri.toString(), | |||||
dateFormat: dateFormat, | |||||
access: access, | |||||
}; | |||||
} | |||||
private getPeriodDate(): Date { | |||||
const period = this._period ?? '3|M'; | |||||
const [number, unit] = period.split('|'); | |||||
switch (unit) { | |||||
case 'D': | |||||
return createFromDateDelta(new Date(), { days: -parseInt(number, 10) }); | |||||
case 'M': | |||||
return createFromDateDelta(new Date(), { months: -parseInt(number, 10) }); | |||||
case 'Y': | |||||
return createFromDateDelta(new Date(), { years: -parseInt(number, 10) }); | |||||
default: | |||||
return createFromDateDelta(new Date(), { months: -3 }); | |||||
} | |||||
} | |||||
private async notifyDidChangeState(editor: TextEditor | undefined) { | |||||
if (!this.isReady) return false; | |||||
return window.withProgress({ location: ProgressLocation.Window }, async () => | |||||
this.notify(DidChangeStateNotificationType, { | |||||
state: await this.getState(editor), | |||||
}), | |||||
); | |||||
} | |||||
} |
@ -0,0 +1,218 @@ | |||||
'use strict'; | |||||
import { commands, Disposable, TextEditor, window } from 'vscode'; | |||||
import { ShowQuickCommitCommandArgs } from '../../../commands'; | |||||
import { Commands } from '../../../constants'; | |||||
import { Container } from '../../../container'; | |||||
import { PremiumFeatures } from '../../../features'; | |||||
import { GitUri } from '../../../git/gitUri'; | |||||
import { createFromDateDelta } from '../../../system/date'; | |||||
import { debug } from '../../../system/decorators/log'; | |||||
import { debounce } from '../../../system/function'; | |||||
import { hasVisibleTextEditor, isTextEditor } from '../../../system/utils'; | |||||
import { IpcMessage, onIpc } from '../../../webviews/protocol'; | |||||
import { WebviewViewBase } from '../../../webviews/webviewViewBase'; | |||||
import type { SubscriptionChangeEvent } from '../../subscription/subscriptionService'; | |||||
import { | |||||
Commit, | |||||
DidChangeStateNotificationType, | |||||
OpenDataPointCommandType, | |||||
State, | |||||
UpdatePeriodCommandType, | |||||
} from './protocol'; | |||||
export class TimelineWebviewView extends WebviewViewBase<State> { | |||||
private _editor: TextEditor | undefined; | |||||
private _period: `${number}|${'D' | 'M' | 'Y'}` = '3|M'; | |||||
constructor(container: Container) { | |||||
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(); | |||||
} | |||||
protected override onReady() { | |||||
this.onActiveEditorChanged(window.activeTextEditor); | |||||
} | |||||
protected override async includeBootstrap(): Promise<State> { | |||||
return this.getState(undefined); | |||||
} | |||||
@debug({ args: false }) | |||||
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; | |||||
this._editor = editor; | |||||
void this.notifyDidChangeState(editor); | |||||
} | |||||
private onSubscriptionChanged(_e: SubscriptionChangeEvent) { | |||||
void this.notifyDidChangeState(this._editor); | |||||
} | |||||
private _disposableVisibility: Disposable | undefined; | |||||
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; | |||||
this._editor = undefined; | |||||
} | |||||
} | |||||
protected override onMessageReceived(e: IpcMessage) { | |||||
switch (e.method) { | |||||
case OpenDataPointCommandType.method: | |||||
onIpc(OpenDataPointCommandType, e, params => { | |||||
if (params.data == null || this._editor == null || !params.data.selected) return; | |||||
const repository = this.container.git.getRepository(this._editor.document.uri); | |||||
if (repository == null) return; | |||||
const commandArgs: ShowQuickCommitCommandArgs = { | |||||
repoPath: repository.path, | |||||
sha: params.data.id, | |||||
}; | |||||
void commands.executeCommand(Commands.ShowQuickCommit, commandArgs); | |||||
// const commandArgs: DiffWithPreviousCommandArgs = { | |||||
// line: 0, | |||||
// showOptions: { | |||||
// preserveFocus: true, | |||||
// preview: true, | |||||
// viewColumn: ViewColumn.Beside, | |||||
// }, | |||||
// }; | |||||
// void commands.executeCommand( | |||||
// Commands.DiffWithPrevious, | |||||
// new GitUri(gitUri, { repoPath: gitUri.repoPath!, sha: params.data.id }), | |||||
// commandArgs, | |||||
// ); | |||||
}); | |||||
break; | |||||
case UpdatePeriodCommandType.method: | |||||
onIpc(UpdatePeriodCommandType, e, params => { | |||||
this._period = params.period; | |||||
void this.notifyDidChangeState(this._editor); | |||||
}); | |||||
break; | |||||
} | |||||
} | |||||
@debug({ args: { 0: e => e?.document.uri.toString(true) } }) | |||||
private async getState(editor: TextEditor | undefined): Promise<State> { | |||||
const access = await this.container.git.access(PremiumFeatures.Timeline); | |||||
const dateFormat = this.container.config.defaultDateFormat ?? 'MMMM Do, YYYY h:mma'; | |||||
if (editor == null || !access.allowed) { | |||||
return { | |||||
period: this._period, | |||||
title: 'There are no editors open that can provide file history information', | |||||
dateFormat: dateFormat, | |||||
access: access, | |||||
}; | |||||
} | |||||
const gitUri = await GitUri.fromUri(editor.document.uri); | |||||
const repoPath = gitUri.repoPath!; | |||||
const title = gitUri.relativePath; | |||||
// this.setTitle(`${this.title} \u2022 ${gitUri.fileName}`); | |||||
this.description = gitUri.fileName; | |||||
const [currentUser, log] = await Promise.all([ | |||||
this.container.git.getCurrentUser(repoPath), | |||||
this.container.git.getLogForFile(repoPath, gitUri.fsPath, { | |||||
limit: 0, | |||||
ref: gitUri.sha, | |||||
since: this.getPeriodDate().toISOString(), | |||||
}), | |||||
]); | |||||
if (log == null) { | |||||
return { | |||||
dataset: [], | |||||
period: this._period, | |||||
title: title, | |||||
uri: editor.document.uri.toString(), | |||||
dateFormat: dateFormat, | |||||
access: access, | |||||
}; | |||||
} | |||||
const name = currentUser?.name ? `${currentUser.name} (you)` : 'You'; | |||||
const dataset: Commit[] = []; | |||||
for (const commit of log.commits.values()) { | |||||
const stats = commit.file?.stats; | |||||
dataset.push({ | |||||
author: commit.author.name === 'You' ? name : commit.author.name, | |||||
additions: stats?.additions ?? 0, | |||||
// changed: stats?.changes ?? 0, | |||||
deletions: stats?.deletions ?? 0, | |||||
commit: commit.sha, | |||||
date: commit.date.toISOString(), | |||||
message: commit.message ?? commit.summary, | |||||
sort: commit.date.getTime(), | |||||
}); | |||||
} | |||||
dataset.sort((a, b) => b.sort - a.sort); | |||||
return { | |||||
dataset: dataset, | |||||
period: this._period, | |||||
title: title, | |||||
uri: editor.document.uri.toString(), | |||||
dateFormat: dateFormat, | |||||
access: access, | |||||
}; | |||||
} | |||||
private getPeriodDate(): Date { | |||||
const period = this._period ?? '3|M'; | |||||
const [number, unit] = period.split('|'); | |||||
switch (unit) { | |||||
case 'D': | |||||
return createFromDateDelta(new Date(), { days: -parseInt(number, 10) }); | |||||
case 'M': | |||||
return createFromDateDelta(new Date(), { months: -parseInt(number, 10) }); | |||||
case 'Y': | |||||
return createFromDateDelta(new Date(), { years: -parseInt(number, 10) }); | |||||
default: | |||||
return createFromDateDelta(new Date(), { months: -3 }); | |||||
} | |||||
} | |||||
private async notifyDidChangeState(editor: TextEditor | undefined) { | |||||
if (!this.isReady) return false; | |||||
return window.withProgress({ location: { viewId: this.id } }, async () => | |||||
this.notify(DidChangeStateNotificationType, { | |||||
state: await this.getState(editor), | |||||
}), | |||||
); | |||||
} | |||||
} |
@ -0,0 +1,490 @@ | |||||
.bb { | |||||
svg { | |||||
// font-size: 12px; | |||||
// line-height: 1; | |||||
font: 10px sans-serif; | |||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0); | |||||
} | |||||
path, | |||||
line { | |||||
fill: none; | |||||
// stroke: #000; | |||||
} | |||||
path.domain { | |||||
.vscode-dark & { | |||||
stroke: var(--color-background--lighten-15); | |||||
} | |||||
.vscode-light & { | |||||
stroke: var(--color-background--darken-15); | |||||
} | |||||
} | |||||
text, | |||||
.bb-button { | |||||
user-select: none; | |||||
// fill: var(--color-foreground--75); | |||||
fill: var(--color-view-foreground); | |||||
font-size: 11px; | |||||
} | |||||
.bb-legend-item-tile, | |||||
.bb-xgrid-focus, | |||||
.bb-ygrid-focus, | |||||
.bb-ygrid, | |||||
.bb-event-rect, | |||||
.bb-bars path { | |||||
shape-rendering: crispEdges; | |||||
} | |||||
.bb-chart-arc { | |||||
.bb-gauge-value { | |||||
fill: #000; | |||||
} | |||||
path { | |||||
stroke: #fff; | |||||
} | |||||
rect { | |||||
stroke: #fff; | |||||
stroke-width: 1; | |||||
} | |||||
text { | |||||
fill: #fff; | |||||
font-size: 13px; | |||||
} | |||||
} | |||||
/*-- Axis --*/ | |||||
.bb-axis { | |||||
shape-rendering: crispEdges; | |||||
} | |||||
// .bb-axis-y, | |||||
// .bb-axis-y2 { | |||||
// text { | |||||
// fill: var(--color-foreground--75); | |||||
// } | |||||
// } | |||||
// .bb-event-rects { | |||||
// fill-opacity: 1 !important; | |||||
// .bb-event-rect { | |||||
// fill: transparent; | |||||
// &._active_ { | |||||
// fill: rgba(39, 201, 3, 0.05); | |||||
// } | |||||
// } | |||||
// } | |||||
// .tick._active_ text { | |||||
// fill: #00c83c !important; | |||||
// } | |||||
/*-- Grid --*/ | |||||
.bb-grid { | |||||
pointer-events: none; | |||||
line { | |||||
.vscode-dark & { | |||||
stroke: var(--color-background--lighten-075); | |||||
&.bb-ygrid { | |||||
stroke: var(--color-background--lighten-15); | |||||
} | |||||
} | |||||
.vscode-light & { | |||||
stroke: var(--color-background--darken-075); | |||||
&.bb-ygrid { | |||||
stroke: var(--color-background--darken-15); | |||||
} | |||||
} | |||||
} | |||||
text { | |||||
// fill: #aaa; | |||||
fill: var(--color-view-foreground); | |||||
} | |||||
} | |||||
.bb-xgrid { | |||||
stroke-dasharray: 2 2; | |||||
} | |||||
.bb-ygrid { | |||||
stroke-dasharray: 2 1; | |||||
} | |||||
.bb-xgrid-focus { | |||||
line { | |||||
.vscode-dark & { | |||||
stroke: var(--color-background--lighten-30); | |||||
} | |||||
.vscode-light & { | |||||
stroke: var(--color-background--darken-30); | |||||
} | |||||
} | |||||
} | |||||
/*-- Text on Chart --*/ | |||||
.bb-text.bb-empty { | |||||
fill: #808080; | |||||
font-size: 2em; | |||||
} | |||||
/*-- Line --*/ | |||||
.bb-line { | |||||
stroke-width: 1px; | |||||
} | |||||
/*-- Point --*/ | |||||
.bb-circle { | |||||
// opacity: 0.8 !important; | |||||
// stroke-width: 0; | |||||
// fill-opacity: 0.6; | |||||
&._expanded_ { | |||||
// stroke-width: 1px; | |||||
// stroke: white; | |||||
// opacity: 1 !important; | |||||
fill-opacity: 1; | |||||
stroke-width: 3px; | |||||
stroke-opacity: 0.6; | |||||
} | |||||
} | |||||
.bb-selected-circle { | |||||
// fill: white; | |||||
// stroke-width: 2px; | |||||
// opacity: 1 !important; | |||||
fill-opacity: 1; | |||||
stroke-width: 3px; | |||||
stroke-opacity: 0.6; | |||||
} | |||||
// .bb-circles { | |||||
// &.bb-focused { | |||||
// opacity: 1; | |||||
// } | |||||
// &.bb-defocused { | |||||
// opacity: 0.3 !important; | |||||
// } | |||||
// } | |||||
// rect.bb-circle, | |||||
// use.bb-circle { | |||||
// &._expanded_ { | |||||
// stroke-width: 3px; | |||||
// } | |||||
// } | |||||
// .bb-selected-circle { | |||||
// stroke-width: 2px; | |||||
// .vscode-dark & { | |||||
// fill: rgba(255, 255, 255, 0.2); | |||||
// } | |||||
// .vscode-light & { | |||||
// fill: rgba(0, 0, 0, 0.1); | |||||
// } | |||||
// } | |||||
/*-- Bar --*/ | |||||
.bb-bar { | |||||
stroke-width: 0; | |||||
// opacity: 0.8 !important; | |||||
// fill-opacity: 0.6; | |||||
&._expanded_ { | |||||
fill-opacity: 0.75; | |||||
// opacity: 1 !important; | |||||
// stroke-width: 3px; | |||||
// stroke-opacity: 0.6; | |||||
} | |||||
} | |||||
/*-- Candlestick --*/ | |||||
.bb-candlestick { | |||||
stroke-width: 1px; | |||||
&._expanded_ { | |||||
fill-opacity: 0.75; | |||||
} | |||||
} | |||||
/*-- Focus --*/ | |||||
.bb-target.bb-focused, | |||||
.bb-circles.bb-focused { | |||||
opacity: 1; | |||||
} | |||||
.bb-target.bb-focused path.bb-line, | |||||
.bb-target.bb-focused path.bb-step, | |||||
.bb-circles.bb-focused path.bb-line, | |||||
.bb-circles.bb-focused path.bb-step { | |||||
stroke-width: 2px; | |||||
} | |||||
.bb-target.bb-defocused, | |||||
.bb-circles.bb-defocused { | |||||
opacity: 0.3 !important; | |||||
} | |||||
.bb-target.bb-defocused .text-overlapping, | |||||
.bb-circles.bb-defocused .text-overlapping { | |||||
opacity: 0.05 !important; | |||||
} | |||||
/*-- Region --*/ | |||||
.bb-region { | |||||
fill: steelblue; | |||||
fill-opacity: 0.1; | |||||
} | |||||
/*-- Zoom region --*/ | |||||
.bb-zoom-brush { | |||||
.vscode-dark & { | |||||
fill: white; | |||||
fill-opacity: 0.2; | |||||
} | |||||
.vscode-light & { | |||||
fill: black; | |||||
fill-opacity: 0.1; | |||||
} | |||||
} | |||||
/*-- Brush --*/ | |||||
.bb-brush { | |||||
.extent { | |||||
fill-opacity: 0.1; | |||||
} | |||||
} | |||||
/*-- Legend --*/ | |||||
.bb-legend-item { | |||||
font-size: 12px; | |||||
user-select: none; | |||||
} | |||||
.bb-legend-item-hidden { | |||||
opacity: 0.15; | |||||
} | |||||
.bb-legend-background { | |||||
opacity: 0.75; | |||||
fill: white; | |||||
stroke: lightgray; | |||||
stroke-width: 1; | |||||
} | |||||
/*-- Title --*/ | |||||
.bb-title { | |||||
font: 14px sans-serif; | |||||
} | |||||
/*-- Tooltip --*/ | |||||
.bb-tooltip-container { | |||||
z-index: 10; | |||||
user-select: none; | |||||
} | |||||
.bb-tooltip { | |||||
display: flex; | |||||
border-collapse: collapse; | |||||
border-spacing: 0; | |||||
background-color: var(--color-hover-background); | |||||
color: var(--color-hover-foreground); | |||||
empty-cells: show; | |||||
opacity: 1; | |||||
box-shadow: 7px 7px 12px -9px var(--color-hover-border); | |||||
font-size: var(--font-size); | |||||
font-family: var(--font-family); | |||||
tbody { | |||||
border: 1px solid var(--color-hover-border); | |||||
} | |||||
th { | |||||
padding: 0.5rem 1rem; | |||||
text-align: left; | |||||
} | |||||
tr { | |||||
&:not(.bb-tooltip-name-additions, .bb-tooltip-name-deletions) { | |||||
display: flex; | |||||
flex-direction: column-reverse; | |||||
td { | |||||
&.name { | |||||
display: flex; | |||||
align-items: center; | |||||
font-size: 12px; | |||||
font-family: var(--editor-font-family); | |||||
background-color: var(--color-hover-statusBarBackground); | |||||
border-top: 1px solid var(--color-hover-border); | |||||
border-bottom: 1px solid var(--color-hover-border); | |||||
padding: 0.5rem; | |||||
line-height: 2rem; | |||||
&:before { | |||||
font: normal normal normal 16px/1 codicon; | |||||
display: inline-block; | |||||
text-decoration: none; | |||||
text-rendering: auto; | |||||
text-align: center; | |||||
-webkit-font-smoothing: antialiased; | |||||
-moz-osx-font-smoothing: grayscale; | |||||
user-select: none; | |||||
vertical-align: middle; | |||||
line-height: 2rem; | |||||
padding-right: 0.25rem; | |||||
content: '\eafc'; | |||||
} | |||||
span { | |||||
display: none; | |||||
} | |||||
} | |||||
&.value { | |||||
display: flex; | |||||
padding: 0.25rem 2rem 1rem 2rem; | |||||
white-space: pre-line; | |||||
word-wrap: break-word; | |||||
overflow-wrap: break-word; | |||||
max-width: 450px; | |||||
max-height: 450px; | |||||
overflow-y: scroll; | |||||
overflow-x: hidden; | |||||
} | |||||
} | |||||
} | |||||
&.bb-tooltip-name-additions, | |||||
&.bb-tooltip-name-deletions { | |||||
display: inline-flex; | |||||
flex-direction: row-reverse; | |||||
justify-content: center; | |||||
width: calc(50% - 2rem); | |||||
margin: 0px 1rem; | |||||
td { | |||||
&.name { | |||||
text-transform: lowercase; | |||||
} | |||||
&.value { | |||||
margin-right: 0.25rem; | |||||
} | |||||
} | |||||
} | |||||
} | |||||
// td > span, | |||||
// td > svg { | |||||
// display: inline-block; | |||||
// width: 10px; | |||||
// height: 10px; | |||||
// margin-right: 6px; | |||||
// } | |||||
} | |||||
/*-- Area --*/ | |||||
.bb-area { | |||||
stroke-width: 0; | |||||
opacity: 0.2; | |||||
} | |||||
/*-- Arc --*/ | |||||
.bb-chart-arcs-title { | |||||
dominant-baseline: middle; | |||||
font-size: 1.3em; | |||||
} | |||||
text.bb-chart-arcs-gauge-title { | |||||
dominant-baseline: middle; | |||||
font-size: 2.7em; | |||||
} | |||||
.bb-chart-arcs .bb-chart-arcs-background { | |||||
fill: #e0e0e0; | |||||
stroke: #fff; | |||||
} | |||||
.bb-chart-arcs .bb-chart-arcs-gauge-unit { | |||||
fill: #000; | |||||
font-size: 16px; | |||||
} | |||||
.bb-chart-arcs .bb-chart-arcs-gauge-max { | |||||
fill: #777; | |||||
} | |||||
.bb-chart-arcs .bb-chart-arcs-gauge-min { | |||||
fill: #777; | |||||
} | |||||
/*-- Radar --*/ | |||||
.bb-chart-radars .bb-levels polygon { | |||||
fill: none; | |||||
stroke: #848282; | |||||
stroke-width: 0.5px; | |||||
} | |||||
.bb-chart-radars .bb-levels text { | |||||
fill: #848282; | |||||
} | |||||
.bb-chart-radars .bb-axis line { | |||||
stroke: #848282; | |||||
stroke-width: 0.5px; | |||||
} | |||||
.bb-chart-radars .bb-axis text { | |||||
font-size: 1.15em; | |||||
cursor: default; | |||||
} | |||||
.bb-chart-radars .bb-shapes polygon { | |||||
fill-opacity: 0.2; | |||||
stroke-width: 1px; | |||||
} | |||||
/*-- Button --*/ | |||||
.bb-button { | |||||
position: absolute; | |||||
top: 0; | |||||
right: 2rem; | |||||
border: 1px solid var(--color-button-background); | |||||
background-color: var(--color-button-background); | |||||
color: var(--color-button-foreground); | |||||
font-size: var(--font-size); | |||||
font-family: var(--font-family); | |||||
.bb-zoom-reset { | |||||
display: inline-block; | |||||
padding: 0.5rem 1rem; | |||||
cursor: pointer; | |||||
} | |||||
} | |||||
} |
@ -0,0 +1,331 @@ | |||||
'use strict'; | |||||
/*global*/ | |||||
import { bar, bb, bubble, Chart, ChartOptions, DataItem, selection, zoom } from 'billboard.js'; | |||||
// import BubbleCompare from 'billboard.js/dist/plugin/billboardjs-plugin-bubblecompare'; | |||||
import { Commit, State } from '../../../../premium/webviews/timeline/protocol'; | |||||
import { formatDate, fromNow } from '../../shared/date'; | |||||
import { Emitter, Event } from '../../shared/events'; | |||||
export interface DataPointClickEvent { | |||||
data: { | |||||
id: string; | |||||
selected: boolean; | |||||
}; | |||||
} | |||||
export class TimelineChart { | |||||
private _onDidClickDataPoint = new Emitter<DataPointClickEvent>(); | |||||
get onDidClickDataPoint(): Event<DataPointClickEvent> { | |||||
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 _dateFormat: string = undefined!; | |||||
constructor(selector: string) { | |||||
const config: ChartOptions = { | |||||
bindto: selector, | |||||
data: { | |||||
columns: [], | |||||
types: { time: bubble(), additions: bar(), deletions: bar() }, | |||||
xFormat: '%Y-%m-%dT%H:%M:%S.%LZ', | |||||
xLocaltime: false, | |||||
selection: { | |||||
enabled: selection(), | |||||
draggable: false, | |||||
grouped: false, | |||||
multiple: false, | |||||
}, | |||||
onclick: this.onDataPointClicked.bind(this), | |||||
}, | |||||
axis: { | |||||
x: { | |||||
type: 'timeseries', | |||||
clipPath: false, | |||||
localtime: true, | |||||
tick: { | |||||
// autorotate: true, | |||||
centered: true, | |||||
culling: false, | |||||
fit: false, | |||||
format: '%-m/%-d/%Y', | |||||
multiline: false, | |||||
// rotate: 15, | |||||
show: false, | |||||
}, | |||||
}, | |||||
y: { | |||||
max: 0, | |||||
padding: { | |||||
top: 50, | |||||
bottom: 100, | |||||
}, | |||||
show: true, | |||||
tick: { | |||||
format: (y: number) => this._authorsByIndex.get(y) ?? '', | |||||
outer: false, | |||||
}, | |||||
}, | |||||
y2: { | |||||
label: { | |||||
text: 'Number of Lines Changed', | |||||
position: 'outer-middle', | |||||
}, | |||||
// min: 0, | |||||
show: true, | |||||
tick: { | |||||
outer: true, | |||||
// culling: true, | |||||
// stepSize: 1, | |||||
}, | |||||
}, | |||||
}, | |||||
bar: { | |||||
width: 2, | |||||
sensitivity: 4, | |||||
padding: 2, | |||||
}, | |||||
bubble: { | |||||
maxR: 50, | |||||
}, | |||||
grid: { | |||||
focus: { | |||||
edge: true, | |||||
show: true, | |||||
y: true, | |||||
}, | |||||
front: true, | |||||
lines: { | |||||
front: false, | |||||
}, | |||||
x: { | |||||
show: false, | |||||
}, | |||||
y: { | |||||
show: true, | |||||
}, | |||||
}, | |||||
legend: { | |||||
show: true, | |||||
padding: 10, | |||||
}, | |||||
// point: { | |||||
// r: 6, | |||||
// focus: { | |||||
// expand: { | |||||
// enabled: true, | |||||
// r: 9, | |||||
// }, | |||||
// }, | |||||
// select: { | |||||
// r: 12, | |||||
// }, | |||||
// }, | |||||
resize: { | |||||
auto: true, | |||||
}, | |||||
tooltip: { | |||||
grouped: true, | |||||
format: { | |||||
title: this.getTooltipTitle.bind(this), | |||||
name: this.getTooltipName.bind(this), | |||||
value: this.getTooltipValue.bind(this), | |||||
}, | |||||
// linked: true, //{ name: 'time' }, | |||||
show: true, | |||||
order: 'desc', | |||||
}, | |||||
zoom: { | |||||
enabled: zoom(), | |||||
type: 'drag', | |||||
rescale: true, | |||||
resetButton: true, | |||||
extent: [1, 0.01], | |||||
x: { | |||||
min: 100, | |||||
}, | |||||
// onzoomstart: function(...args: any[]) { | |||||
// console.log('onzoomstart', args); | |||||
// }, | |||||
// onzoom: function(...args: any[]) { | |||||
// console.log('onzoom', args); | |||||
// }, | |||||
// onzoomend: function(...args: any[]) { | |||||
// console.log('onzoomend', args); | |||||
// } | |||||
}, | |||||
// plugins: [ | |||||
// new BubbleCompare({ | |||||
// minR: 3, | |||||
// maxR: 30, | |||||
// 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, | |||||
}); | |||||
} | |||||
private getTooltipName(name: string, ratio: number, id: string, index: number) { | |||||
if (id === 'additions' || /*id === 'changes' ||*/ id === 'deletions') return name; | |||||
const date = new Date(this._chart.data(id)[0].values[index].x); | |||||
const commit = this._commitsByTimestamp.get(date.toISOString()); | |||||
return commit?.commit.slice(0, 8) ?? '00000000'; | |||||
} | |||||
private getTooltipTitle(x: string) { | |||||
const date = new Date(x); | |||||
const formattedDate = `${capitalize(fromNow(date))} (${formatDate(date, this._dateFormat)})`; | |||||
const commit = this._commitsByTimestamp.get(date.toISOString()); | |||||
if (commit == null) return formattedDate; | |||||
return `${commit.author}, ${formattedDate}`; | |||||
} | |||||
private getTooltipValue(value: any, ratio: number, id: string, index: number) { | |||||
if (id === 'additions' || /*id === 'changes' ||*/ id === 'deletions') { | |||||
return value === 0 ? undefined! : value; | |||||
} | |||||
const date = new Date(this._chart.data(id)[0].values[index].x); | |||||
const commit = this._commitsByTimestamp.get(date.toISOString()); | |||||
return commit?.message ?? '???'; | |||||
} | |||||
} | |||||
function capitalize(s: string): string { | |||||
return s.charAt(0).toUpperCase() + s.slice(1); | |||||
} |
@ -0,0 +1,18 @@ | |||||
<template id="state:free-preview-expired"> | |||||
<section> | |||||
<!-- <h3>Continue using Premium Features</h3> --> | |||||
<p data-visible="public"> | |||||
The Visual File History is a premium feature, which requires a free account for public repos. | |||||
</p> | |||||
<p data-visible="private"> | |||||
The Visual File History is a premium feature, which requires at least a Pro subscription for private repos. | |||||
</p> | |||||
<p>Create a free account to continue trialing premium features for all code for an additional 7 days.</p> | |||||
<vscode-button data-action="command:gitlens.premium.learn">Learn about premium features</vscode-button> | |||||
<vscode-button data-action="command:gitlens.premium.loginOrSignUp">Create a free account</vscode-button> | |||||
<span class="button-subaction" | |||||
>Have an existing account? <a href="command:gitlens.premium.loginOrSignUp">Sign in</a></span | |||||
> | |||||
<vscode-button data-action="command:gitlens.premium.purchase">Purchase a plan</vscode-button> | |||||
</section> | |||||
</template> |
@ -0,0 +1,20 @@ | |||||
<template id="state:free"> | |||||
<section> | |||||
<!-- <h3>Try GitLens Premium Features</h3> --> | |||||
<p data-visible="public"> | |||||
The Visual File History is a premium feature, which requires a free account for public repos. | |||||
</p> | |||||
<p data-visible="private"> | |||||
The Visual File History is a premium feature, which requires at least a Pro subscription for private repos. | |||||
</p> | |||||
<vscode-button data-action="command:gitlens.premium.learn">Learn about premium features</vscode-button> | |||||
<vscode-button data-action="command:gitlens.premium.startPreviewTrial">Try premium features now</vscode-button> | |||||
<vscode-button data-action="command:gitlens.premium.loginOrSignUp">Create a free account</vscode-button> | |||||
<span class="button-subaction" | |||||
>Have an existing account? <a href="command:gitlens.premium.loginOrSignUp">Sign in</a></span | |||||
> | |||||
<vscode-button data-action="command:gitlens.premium.showPlans" appearance="secondary" | |||||
>View paid plans</vscode-button | |||||
> | |||||
</section> | |||||
</template> |
@ -0,0 +1,10 @@ | |||||
<template id="state:plus-trial-expired"> | |||||
<section> | |||||
<!-- <h3>GitLens Free+</h3> --> | |||||
<p> | |||||
The Visual File History is a premium feature, which requires at least a Pro subscription for private repos. | |||||
</p> | |||||
<vscode-button data-action="command:gitlens.premium.learn">Learn about premium features</vscode-button> | |||||
<vscode-button data-action="command:gitlens.premium.purchase">Purchase a plan</vscode-button> | |||||
</section> | |||||
</template> |
@ -0,0 +1,11 @@ | |||||
<template id="state:verify-email"> | |||||
<section> | |||||
<!-- <h3>Please verify your email</h3> --> | |||||
<p>To continue using premium GitLens features, please verify the email for the account you created.</p> | |||||
<vscode-button data-action="command:gitlens.premium.resendVerification" | |||||
>Resend verification email</vscode-button | |||||
> | |||||
<vscode-button data-action="command:gitlens.premium.validate">Refresh verification status</vscode-button> | |||||
<p>All non-premium features will continue to be free without an account.</p> | |||||
</section> | |||||
</template> |
@ -0,0 +1,5 @@ | |||||
declare module 'billboard.js/dist/plugin/billboardjs-plugin-bubblecompare' { | |||||
import BubbleCompare from 'billboard.js/types/plugin/bubblecompare'; | |||||
export = BubbleCompare; | |||||
} |
@ -0,0 +1,59 @@ | |||||
<!DOCTYPE html> | |||||
<html lang="en"> | |||||
<head> | |||||
<meta charset="utf-8" /> | |||||
</head> | |||||
<body class="preload"> | |||||
<div class="container"> | |||||
<section id="header"> | |||||
<h2 data-bind="title"></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> | |||||
</section> | |||||
<section id="content"> | |||||
<div id="chart"></div> | |||||
</section> | |||||
<section id="footer"></section> | |||||
</div> | |||||
<div id="overlay"> | |||||
<p> | |||||
The | |||||
<a href="command:gitlens.openWalkthrough?%22gitlens.premium%7Cgitlens.premium.visualFileHistory%22" | |||||
>Visual File History</a | |||||
> | |||||
allows you to clearly see the history of a file, including: when changes were made, how large they were, | |||||
and who made them. Authors who have contributed changes to a file are on one axis, and dates on the | |||||
other. Color-coded dots represent points in time where that author made changes to the file, and | |||||
vertical lines represent the magnitude of the change in two colors: green for lines added, and red for | |||||
lines removed. | |||||
</p> | |||||
<div id="overlay-slot"></div> | |||||
</div> | |||||
#{endOfBody} | |||||
<style nonce="#{cspNonce}"> | |||||
@font-face { | |||||
font-family: 'codicon'; | |||||
src: url('#{root}/dist/webviews/codicon.ttf?669d352dfabff8f6eaa466c8ae820e43') format('truetype'); | |||||
} | |||||
</style> | |||||
</body> | |||||
<!-- prettier-ignore --> | |||||
<%= require('html-loader?{"esModule":false}!./partials/state.free.html') %> | |||||
<%= require('html-loader?{"esModule":false}!./partials/state.free-preview-expired.html') %> | |||||
<%= require('html-loader?{"esModule":false}!./partials/state.plus-trial-expired.html') %> | |||||
<%= require('html-loader?{"esModule":false}!./partials/state.verify-email.html') %> | |||||
</html> |
@ -0,0 +1,165 @@ | |||||
* { | |||||
box-sizing: border-box; | |||||
} | |||||
html { | |||||
height: 100%; | |||||
font-size: 62.5%; | |||||
} | |||||
body { | |||||
background-color: var(--color-background); | |||||
color: var(--color-view-foreground); | |||||
font-family: var(--font-family); | |||||
height: 100%; | |||||
line-height: 1.4; | |||||
font-size: 100% !important; | |||||
overflow: hidden; | |||||
margin: 0 20px 20px 20px; | |||||
padding: 0; | |||||
} | |||||
.container { | |||||
display: grid; | |||||
grid-template-rows: min-content 1fr min-content; | |||||
min-height: 100%; | |||||
// width: 100%; | |||||
} | |||||
section { | |||||
display: flex; | |||||
flex-direction: column; | |||||
padding: 0; | |||||
} | |||||
h2 { | |||||
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 { | |||||
border: none; | |||||
color: var(--color-view-header-foreground); | |||||
font-size: 1.5rem; | |||||
font-weight: 600; | |||||
margin-bottom: 0; | |||||
white-space: nowrap; | |||||
} | |||||
h4 { | |||||
font-size: 1.5rem; | |||||
font-weight: 400; | |||||
margin: 0.5rem 0 1rem 0; | |||||
} | |||||
a { | |||||
text-decoration: none; | |||||
&:focus { | |||||
outline-color: var(--focus-border); | |||||
} | |||||
&:hover { | |||||
text-decoration: underline; | |||||
} | |||||
} | |||||
b { | |||||
font-weight: 600; | |||||
} | |||||
p { | |||||
margin-bottom: 0; | |||||
} | |||||
vscode-button { | |||||
align-self: center; | |||||
margin-top: 1.5rem; | |||||
max-width: 300px; | |||||
width: 100%; | |||||
} | |||||
span.button-subaction { | |||||
align-self: center; | |||||
margin-top: 0.75rem; | |||||
} | |||||
@media (min-width: 640px) { | |||||
vscode-button { | |||||
align-self: flex-start; | |||||
} | |||||
span.button-subaction { | |||||
align-self: flex-start; | |||||
} | |||||
} | |||||
section#header { | |||||
display: flex; | |||||
flex-direction: row; | |||||
align-items: baseline; | |||||
@media all and (max-width: 500px) { | |||||
flex-wrap: wrap; | |||||
} | |||||
} | |||||
.select-container { | |||||
display: flex; | |||||
align-items: center; | |||||
justify-content: flex-end; | |||||
margin: 1em; | |||||
flex: 100% 0 1; | |||||
label { | |||||
margin-right: 1em; | |||||
font-size: var(--font-size); | |||||
} | |||||
} | |||||
// .chart-container { | |||||
// height: calc(100% - 0.5em); | |||||
// width: 100%; | |||||
// overflow: hidden; | |||||
// } | |||||
#chart { | |||||
height: 100%; | |||||
width: 100%; | |||||
} | |||||
[data-visible] { | |||||
display: none; | |||||
} | |||||
#overlay { | |||||
position: absolute; | |||||
top: 0; | |||||
left: 0; | |||||
width: 100%; | |||||
height: 100%; | |||||
background-color: var(--color-background); | |||||
font-size: 1.3em; | |||||
min-height: 100%; | |||||
padding: 0 2rem 2rem 2rem; | |||||
display: none; | |||||
grid-template-rows: min-content; | |||||
&.subscription-required { | |||||
display: grid; | |||||
} | |||||
} | |||||
@import './chart'; |
@ -0,0 +1,171 @@ | |||||
'use strict'; | |||||
/*global*/ | |||||
import './timeline.scss'; | |||||
import { provideVSCodeDesignSystem, vsCodeButton, vsCodeDropdown, vsCodeOption } from '@vscode/webview-ui-toolkit'; | |||||
import { | |||||
DidChangeStateNotificationType, | |||||
OpenDataPointCommandType, | |||||
State, | |||||
UpdatePeriodCommandType, | |||||
} from '../../../../premium/webviews/timeline/protocol'; | |||||
import { SubscriptionPlanId, SubscriptionState } from '../../../../subscription'; | |||||
import { ExecuteCommandType, IpcMessage, onIpc } from '../../../protocol'; | |||||
import { App } from '../../shared/appBase'; | |||||
import { DOM } from '../../shared/dom'; | |||||
import { DataPointClickEvent, TimelineChart } from './chart'; | |||||
export class TimelineApp extends App<State> { | |||||
private _chart: TimelineChart | undefined; | |||||
constructor() { | |||||
super('TimelineApp'); | |||||
} | |||||
protected override onInitialize() { | |||||
provideVSCodeDesignSystem().register({ | |||||
register: function (container: any, context: any) { | |||||
vsCodeButton().register(container, context); | |||||
vsCodeDropdown().register(container, context); | |||||
vsCodeOption().register(container, context); | |||||
}, | |||||
}); | |||||
this.updateState(); | |||||
} | |||||
protected override onBind() { | |||||
const disposables = super.onBind?.() ?? []; | |||||
disposables.push( | |||||
DOM.on('[data-action]', 'click', (e, target: HTMLElement) => this.onActionClicked(e, target)), | |||||
DOM.on(document, 'keydown', (e: KeyboardEvent) => this.onKeyDown(e)), | |||||
DOM.on(document.getElementById('periods')! as HTMLSelectElement, 'change', (e, target) => | |||||
this.onPeriodChanged(e, target), | |||||
), | |||||
); | |||||
return disposables; | |||||
} | |||||
protected override onMessageReceived(e: MessageEvent) { | |||||
const msg = e.data as IpcMessage; | |||||
switch (msg.method) { | |||||
case DidChangeStateNotificationType.method: | |||||
this.log(`${this.appName}.onMessageReceived(${msg.id}): name=${msg.method}`); | |||||
onIpc(DidChangeStateNotificationType, msg, params => { | |||||
this.state = params.state; | |||||
this.updateState(); | |||||
}); | |||||
break; | |||||
default: | |||||
super.onMessageReceived?.(e); | |||||
} | |||||
} | |||||
private onActionClicked(e: MouseEvent, target: HTMLElement) { | |||||
const action = target.dataset.action; | |||||
if (action?.startsWith('command:')) { | |||||
this.sendCommand(ExecuteCommandType, { command: action.slice(8) }); | |||||
} | |||||
} | |||||
private onChartDataPointClicked(e: DataPointClickEvent) { | |||||
this.sendCommand(OpenDataPointCommandType, e); | |||||
} | |||||
private onKeyDown(e: KeyboardEvent) { | |||||
if (e.key === 'Escape' || e.key === 'Esc') { | |||||
this._chart?.reset(); | |||||
} | |||||
} | |||||
private onPeriodChanged(e: Event, element: HTMLSelectElement) { | |||||
const value = element.options[element.selectedIndex].value; | |||||
assertPeriod(value); | |||||
this.log(`${this.appName}.onPeriodChanged: name=${element.name}, value=${value}`); | |||||
this.sendCommand(UpdatePeriodCommandType, { period: value }); | |||||
} | |||||
private updateState(): void { | |||||
const $overlay = document.getElementById('overlay') as HTMLDivElement; | |||||
$overlay.classList.toggle('subscription-required', !this.state.access.allowed); | |||||
const $slot = document.getElementById('overlay-slot') as HTMLDivElement; | |||||
if (!this.state.access.allowed) { | |||||
const { current: subscription, required } = this.state.access.subscription; | |||||
const requiresPublic = required === SubscriptionPlanId.FreePlus; | |||||
const options = { visible: { public: requiresPublic, private: !requiresPublic } }; | |||||
if (subscription.account?.verified === false) { | |||||
DOM.insertTemplate('state:verify-email', $slot, options); | |||||
return; | |||||
} | |||||
switch (subscription.state) { | |||||
case SubscriptionState.Free: | |||||
DOM.insertTemplate('state:free', $slot, options); | |||||
break; | |||||
case SubscriptionState.FreePreviewExpired: | |||||
DOM.insertTemplate('state:free-preview-expired', $slot, options); | |||||
break; | |||||
case SubscriptionState.FreePlusTrialExpired: | |||||
DOM.insertTemplate('state:plus-trial-expired', $slot, options); | |||||
break; | |||||
} | |||||
return; | |||||
} | |||||
$slot.innerHTML = ''; | |||||
if (this._chart == null) { | |||||
this._chart = new TimelineChart('#chart'); | |||||
this._chart.onDidClickDataPoint(this.onChartDataPointClicked, this); | |||||
} | |||||
let { title } = this.state; | |||||
let description = ''; | |||||
const index = title.lastIndexOf('/'); | |||||
if (index >= 0) { | |||||
const name = title.substring(index + 1); | |||||
description = title.substring(0, index); | |||||
title = name; | |||||
} | |||||
for (const [key, value] of Object.entries({ title: title, description: description })) { | |||||
const $el = document.querySelector(`[data-bind="${key}"]`); | |||||
if ($el != null) { | |||||
$el.textContent = String(value); | |||||
} | |||||
} | |||||
const $periods = document.getElementById('periods') as HTMLSelectElement; | |||||
if ($periods != null) { | |||||
const period = this.state?.period; | |||||
for (let i = 0, len = $periods.options.length; i < len; ++i) { | |||||
if ($periods.options[i].value === period) { | |||||
$periods.selectedIndex = i; | |||||
break; | |||||
} | |||||
} | |||||
} | |||||
this._chart.updateChart(this.state); | |||||
} | |||||
} | |||||
function assertPeriod(period: string): asserts period is `${number}|${'D' | 'M' | 'Y'}` { | |||||
const [value, unit] = period.split('|'); | |||||
if (isNaN(Number(value)) || (unit !== 'D' && unit !== 'M' && unit !== 'Y')) { | |||||
throw new Error(`Invalid period: ${period}`); | |||||
} | |||||
} | |||||
new TimelineApp(); |