@ -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(); |