Browse Source

Reworks the timeline to be compact in views

- Fixes a lot of timeline issues
main
Eric Amodio 1 year ago
parent
commit
1df9fab380
21 changed files with 2293 additions and 1981 deletions
  1. +1
    -2
      src/plus/webviews/timeline/protocol.ts
  2. +1
    -1
      src/plus/webviews/timeline/registration.ts
  3. +31
    -76
      src/plus/webviews/timeline/timelineWebview.ts
  4. +8
    -0
      src/subscription.ts
  5. +5
    -2
      src/system/utils.ts
  6. +88
    -0
      src/webviews/apps/plus/shared/components/plus-feature-gate.ts
  7. +92
    -0
      src/webviews/apps/plus/shared/components/plus-feature-welcome.ts
  8. +15
    -0
      src/webviews/apps/plus/shared/components/vscode.css.ts
  9. +160
    -103
      src/webviews/apps/plus/timeline/chart.ts
  10. +0
    -9
      src/webviews/apps/plus/timeline/partials/state.free-preview-trial-expired.html
  11. +0
    -12
      src/webviews/apps/plus/timeline/partials/state.free.html
  12. +0
    -9
      src/webviews/apps/plus/timeline/partials/state.plus-trial-expired.html
  13. +0
    -8
      src/webviews/apps/plus/timeline/partials/state.verify-email.html
  14. +22
    -34
      src/webviews/apps/plus/timeline/timeline.html
  15. +55
    -121
      src/webviews/apps/plus/timeline/timeline.scss
  16. +26
    -67
      src/webviews/apps/plus/timeline/timeline.ts
  17. +106
    -0
      src/webviews/apps/shared/components/button.ts
  18. +1632
    -1537
      src/webviews/apps/shared/components/code-icon.ts
  19. +19
    -0
      src/webviews/apps/shared/components/styles/lit/a11y.css.ts
  20. +15
    -0
      src/webviews/apps/shared/components/styles/lit/base.css.ts
  21. +17
    -0
      src/webviews/apps/shared/styles/properties.scss

+ 1
- 2
src/plus/webviews/timeline/protocol.ts View File

@ -5,9 +5,8 @@ export interface State {
timestamp: number;
dataset?: Commit[];
emptyMessage?: string;
period: Period;
title: string;
title?: string;
sha?: string;
uri?: string;

+ 1
- 1
src/plus/webviews/timeline/registration.ts View File

@ -16,7 +16,7 @@ export function registerTimelineWebviewPanel(controller: WebviewsController) {
plusFeature: true,
column: ViewColumn.Active,
webviewHostOptions: {
retainContextWhenHidden: true,
retainContextWhenHidden: false,
enableFindWidget: false,
},
},

+ 31
- 76
src/plus/webviews/timeline/timelineWebview.ts View File

@ -55,7 +55,6 @@ export class TimelineWebviewProvider implements WebviewProvider {
etagSubscription: this.container.subscription.etag,
};
this.updatePendingEditor(window.activeTextEditor);
this._context = { ...this._context, ...this._pendingContext };
this._pendingContext = undefined;
@ -134,6 +133,10 @@ export class TimelineWebviewProvider implements WebviewProvider {
onVisibilityChanged(visible: boolean) {
if (!visible) return;
if (this.host.isView()) {
this.updatePendingEditor(window.activeTextEditor);
}
// Since this gets called even the first time the webview is shown, avoid sending an update, because the bootstrap has the data
if (this._bootstraping) {
this._bootstraping = false;
@ -257,44 +260,32 @@ export class TimelineWebviewProvider implements WebviewProvider {
const shortDateFormat = configuration.get('defaultDateShortFormat') ?? 'short';
const period = current.period ?? defaultPeriod;
if (current.uri == null) {
const access = await this.container.git.access(PlusFeatures.Timeline);
return {
timestamp: Date.now(),
emptyMessage: 'There are no editors open that can provide file history information',
period: period,
title: '',
dateFormat: dateFormat,
shortDateFormat: shortDateFormat,
access: access,
};
}
const gitUri = current.uri != null ? await GitUri.fromUri(current.uri) : undefined;
const repoPath = gitUri?.repoPath;
const gitUri = await GitUri.fromUri(current.uri);
const repoPath = gitUri.repoPath!;
if (this.host.isEditor()) {
this.host.title =
gitUri == null ? this.host.originalTitle : `${this.host.originalTitle}: ${gitUri.fileName}`;
} else {
this.host.description = gitUri?.fileName ?? '✨';
}
const access = await this.container.git.access(PlusFeatures.Timeline, repoPath);
if (access.allowed === false) {
const dataset = generateRandomTimelineDataset();
if (current.uri == null || gitUri == null || repoPath == null || access.allowed === false) {
const access = await this.container.git.access(PlusFeatures.Timeline, repoPath);
return {
timestamp: Date.now(),
dataset: dataset.sort((a, b) => b.sort - a.sort),
period: period,
title: 'src/app/index.ts',
uri: Uri.file('src/app/index.ts').toString(),
title: gitUri?.relativePath,
sha: gitUri?.shortSha,
uri: current.uri?.toString(),
dateFormat: dateFormat,
shortDateFormat: shortDateFormat,
access: access,
};
}
const title = gitUri.relativePath;
if (this.host.isEditor()) {
this.host.title = `${this.host.originalTitle}: ${gitUri.fileName}`;
} else {
this.host.description = gitUri.fileName;
}
const [currentUser, log] = await Promise.all([
this.container.git.getCurrentUser(repoPath),
this.container.git.getLogForFile(repoPath, gitUri.fsPath, {
@ -308,9 +299,8 @@ export class TimelineWebviewProvider implements WebviewProvider {
return {
timestamp: Date.now(),
dataset: [],
emptyMessage: 'No commits found for the specified time period',
period: period,
title: title,
title: gitUri.relativePath,
sha: gitUri.shortSha,
uri: current.uri.toString(),
dateFormat: dateFormat,
@ -368,7 +358,7 @@ export class TimelineWebviewProvider implements WebviewProvider {
timestamp: Date.now(),
dataset: dataset,
period: period,
title: title,
title: gitUri.relativePath,
sha: gitUri.shortSha,
uri: current.uri.toString(),
dateFormat: dateFormat,
@ -377,8 +367,8 @@ export class TimelineWebviewProvider implements WebviewProvider {
};
}
private updatePendingContext(context: Partial<Context>): boolean {
const [changed, pending] = updatePendingContext(this._context, this._pendingContext, context);
private updatePendingContext(context: Partial<Context>, force?: boolean): boolean {
const [changed, pending] = updatePendingContext(this._context, this._pendingContext, context, force);
if (changed) {
this._pendingContext = pending;
}
@ -386,14 +376,14 @@ export class TimelineWebviewProvider implements WebviewProvider {
return changed;
}
private updatePendingEditor(editor: TextEditor | undefined): boolean {
if (editor == null && hasVisibleTextEditor()) return false;
private updatePendingEditor(editor: TextEditor | undefined, force?: boolean): boolean {
if (editor == null && hasVisibleTextEditor(this._context.uri ?? this._pendingContext?.uri)) return false;
if (editor != null && !isTextEditor(editor)) return false;
return this.updatePendingUri(editor?.document.uri);
return this.updatePendingUri(editor?.document.uri, force);
}
private updatePendingUri(uri: Uri | undefined): boolean {
private updatePendingUri(uri: Uri | undefined, force?: boolean): boolean {
let etag;
if (uri != null) {
const repository = this.container.git.getRepository(uri);
@ -402,19 +392,13 @@ export class TimelineWebviewProvider implements WebviewProvider {
etag = 0;
}
return this.updatePendingContext({ uri: uri, etagRepository: etag });
return this.updatePendingContext({ uri: uri, etagRepository: etag }, force);
}
private _notifyDidChangeStateDebounced: Deferrable<() => void> | undefined = undefined;
@debug()
private updateState(immediate: boolean = false) {
if (!this.host.ready || !this.host.visible) return;
if (this._pendingContext == null && this.host.isView()) {
this.updatePendingEditor(window.activeTextEditor);
}
if (immediate) {
void this.notifyDidChangeState();
return;
@ -429,22 +413,17 @@ export class TimelineWebviewProvider implements WebviewProvider {
@debug()
private async notifyDidChangeState() {
if (!this.host.ready || !this.host.visible) return false;
this._notifyDidChangeStateDebounced?.cancel();
if (this._pendingContext == null) return false;
const context = { ...this._context, ...this._pendingContext };
this._context = context;
this._pendingContext = undefined;
const task = async () => {
const success = await this.host.notify(DidChangeNotificationType, {
const task = async () =>
this.host.notify(DidChangeNotificationType, {
state: await this.getState(context),
});
if (success) {
this._context = context;
this._pendingContext = undefined;
}
};
if (!this.host.isView()) return task();
return window.withProgress({ location: { viewId: this.host.id } }, task);
@ -458,30 +437,6 @@ export class TimelineWebviewProvider implements WebviewProvider {
}
}
function generateRandomTimelineDataset(): Commit[] {
const dataset: Commit[] = [];
const authors = ['Eric Amodio', 'Justin Roberts', 'Ada Lovelace', 'Grace Hopper'];
const count = 10;
for (let i = 0; i < count; i++) {
// Generate a random date between now and 3 months ago
const date = new Date(new Date().getTime() - Math.floor(Math.random() * (3 * 30 * 24 * 60 * 60 * 1000)));
dataset.push({
commit: String(i),
author: authors[Math.floor(Math.random() * authors.length)],
date: date.toISOString(),
message: '',
// Generate random additions/deletions between 1 and 20, but ensure we have a tiny and large commit
additions: i === 0 ? 2 : i === count - 1 ? 50 : Math.floor(Math.random() * 20) + 1,
deletions: i === 0 ? 1 : i === count - 1 ? 25 : Math.floor(Math.random() * 20) + 1,
sort: date.getTime(),
});
}
return dataset;
}
function getPeriodDate(period: Period): Date | undefined {
if (period == 'all') return undefined;

+ 8
- 0
src/subscription.ts View File

@ -190,3 +190,11 @@ export function isSubscriptionPreviewTrialExpired(subscription: Optional
const remaining = getTimeRemaining(subscription.previewTrial?.expiresOn);
return remaining != null ? remaining <= 0 : undefined;
}
export function isSubscriptionStatePaidOrTrial(state: SubscriptionState): boolean {
return (
state === SubscriptionState.Paid ||
state === SubscriptionState.FreeInPreviewTrial ||
state === SubscriptionState.FreePlusInTrial
);
}

+ 5
- 2
src/system/utils.ts View File

@ -67,10 +67,13 @@ export function getQuickPickIgnoreFocusOut() {
return !configuration.get('advanced.quickPick.closeOnFocusOut');
}
export function hasVisibleTextEditor(): boolean {
export function hasVisibleTextEditor(uri?: Uri): boolean {
if (window.visibleTextEditors.length === 0) return false;
return window.visibleTextEditors.some(e => isTextEditor(e));
if (uri == null) return window.visibleTextEditors.some(e => isTextEditor(e));
const url = uri.toString();
return window.visibleTextEditors.some(e => e.document.uri.toString() === url && isTextEditor(e));
}
export function isActiveDocument(document: TextDocument): boolean {

+ 88
- 0
src/webviews/apps/plus/shared/components/plus-feature-gate.ts View File

@ -0,0 +1,88 @@
import { css, html, LitElement, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { SubscriptionState } from '../../../../../subscription';
import '../../../shared/components/button';
import { linkStyles } from './vscode.css';
@customElement('plus-feature-gate')
export class PlusFeatureGate extends LitElement {
static override styles = [
linkStyles,
css`
:host {
container-type: inline-size;
}
gk-button {
width: 100%;
max-width: 300px;
}
@container (max-width: 640px) {
gk-button {
display: block;
margin-left: auto;
margin-right: auto;
}
}
`,
];
@property({ type: String })
appearance?: 'alert' | 'welcome';
@property({ type: Number })
state?: SubscriptionState;
override render() {
const appearance = (this.appearance ?? 'alert') === 'alert' ? 'alert' : nothing;
switch (this.state) {
case SubscriptionState.VerificationRequired:
return html`
<p>You must verify your email before you can continue.</p>
<gk-button appearance="${appearance}" href="command:gitlens.plus.resendVerification"
>Resend verification email</gk-button
>
<gk-button appearance="${appearance}" href="command:gitlens.plus.validate"
>Refresh verification status</gk-button
>
`;
case SubscriptionState.Free:
return html`
<gk-button appearance="${appearance}" href="command:gitlens.plus.startPreviewTrial"
>Start Free Pro Trial</gk-button
>
<p>
Instantly start a free 3-day Pro trial, or
<a href="command:gitlens.plus.loginOrSignUp">sign in</a>.
</p>
<p> A trial or subscription is required to use this on privately hosted repos.</p>
`;
case SubscriptionState.FreePreviewTrialExpired:
return html`
<p>
Your free 3-day Pro trial has ended, extend your free trial to get an additional 7-days, or
<a href="command:gitlens.plus.loginOrSignUp">sign in</a>.
</p>
<gk-button appearance="${appearance}" href="command:gitlens.plus.loginOrSignUp"
>Extend Free Pro Trial</gk-button
>
<p> A trial or subscription is required to use this on privately hosted repos.</p>
`;
case SubscriptionState.FreePlusTrialExpired:
return html`
<p>Your Pro trial has ended, please upgrade to continue to use this on privately hosted repos.</p>
<gk-button appearance="${appearance}" href="command:gitlens.plus.purchase"
>Upgrade to Pro</gk-button
>
<p> A subscription is required to use this on privately hosted repos.</p>
`;
}
return undefined;
}
}

+ 92
- 0
src/webviews/apps/plus/shared/components/plus-feature-welcome.ts View File

@ -0,0 +1,92 @@
import { css, html, LitElement, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { isSubscriptionStatePaidOrTrial, SubscriptionState } from '../../../../../subscription';
import './plus-feature-gate';
@customElement('plus-feature-welcome')
export class PlusFeatureWelcome extends LitElement {
static override styles = css`
:host {
--background: var(--vscode-sideBar-background);
--foreground: var(--vscode-sideBar-foreground);
--link-foreground: var(--vscode-textLink-foreground);
--link-foreground-active: var(--vscode-textLink-activeForeground);
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
font-size: 1.3rem;
overflow: auto;
z-index: 100;
box-sizing: border-box;
}
:host-context(body[data-placement='editor']) {
--background: transparent;
--foreground: var(--vscode-editor-foreground);
backdrop-filter: blur(3px) saturate(0.8);
padding: 0 2rem;
}
section {
--section-foreground: var(--foreground);
--section-background: var(--background);
--section-border-color: transparent;
display: flex;
flex-direction: column;
padding: 0 2rem 1.3rem 2rem;
background: var(--section-background);
color: var(--section-foreground);
border: 1px solid var(--section-border-color);
height: min-content;
}
:host-context(body[data-placement='editor']) section {
--section-foreground: var(--color-alert-foreground);
--section-background: var(--color-alert-infoBackground);
--section-border-color: var(--color-alert-infoBorder);
--link-decoration-default: underline;
--link-foreground: var(--vscode-foreground);
--link-foreground-active: var(--vscode-foreground);
border-radius: 0.3rem;
max-width: 600px;
max-height: min-content;
margin: 0.2rem auto;
padding: 0 1.3rem;
}
`;
@property({ type: Boolean })
allowed?: boolean;
@property({ type: Number })
state?: SubscriptionState;
@property({ reflect: true })
get appearance() {
return (document.body.getAttribute('data-placement') ?? 'editor') === 'editor' ? 'alert' : 'welcome';
}
override render() {
if (this.allowed || this.state == null || isSubscriptionStatePaidOrTrial(this.state)) {
this.hidden = true;
return undefined;
}
this.hidden = false;
return html`
<section>
<slot hidden=${this.state === SubscriptionState.Free ? nothing : ''}></slot>
<plus-feature-gate appearance=${this.appearance} state=${this.state}></plus-feature-gate>
</section>
`;
}
}

+ 15
- 0
src/webviews/apps/plus/shared/components/vscode.css.ts View File

@ -0,0 +1,15 @@
import { css } from 'lit';
export const linkStyles = css`
a {
color: var(--link-foreground);
text-decoration: var(--link-decoration-default, none);
}
a:focus {
outline-color: var(--focus-border);
}
a:hover {
color: var(--link-foreground-active);
text-decoration: underline;
}
`;

+ 160
- 103
src/webviews/apps/plus/timeline/chart.ts View File

@ -5,7 +5,7 @@ import { bar, bb, bubble, zoom } from 'billboard.js';
// import { scaleSqrt } from 'd3-scale';
import type { Commit, State } from '../../../../plus/webviews/timeline/protocol';
import { formatDate, fromNow } from '../../shared/date';
import type { Event } from '../../shared/events';
import type { Disposable, Event } from '../../shared/events';
import { Emitter } from '../../shared/events';
export interface DataPointClickEvent {
@ -15,7 +15,7 @@ export interface DataPointClickEvent {
};
}
export class TimelineChart {
export class TimelineChart implements Disposable {
private _onDidClickDataPoint = new Emitter<DataPointClickEvent>();
get onDidClickDataPoint(): Event<DataPointClickEvent> {
return this._onDidClickDataPoint.event;
@ -23,9 +23,9 @@ export class TimelineChart {
private readonly $container: HTMLElement;
private _chart: Chart | undefined;
private _chartDimensions: { height: number; width: number };
private readonly _resizeObserver: ResizeObserver;
private readonly _selector: string;
private _size: { height: number; width: number };
private readonly _commitsByTimestamp = new Map<string, Commit>();
private readonly _authorsByIndex = new Map<number, string>();
@ -34,48 +34,48 @@ export class TimelineChart {
private _dateFormat: string = undefined!;
private _shortDateFormat: string = undefined!;
constructor(selector: string) {
this._selector = selector;
private get compact(): boolean {
return this.placement !== 'editor';
}
let idleRequest: number | undefined;
constructor(selector: string, private readonly placement: 'editor' | 'view') {
this._selector = selector;
const fn = () => {
idleRequest = undefined;
const dimensions = this._chartDimensions;
const size = this._size;
this._chart?.resize({
width: dimensions.width,
height: dimensions.height - 10,
width: size.width,
height: size.height,
});
};
const widthOffset = this.compact ? 32 : 0;
const heightOffset = this.compact ? 16 : 0;
this.$container = document.querySelector(selector)!.parentElement!;
this._resizeObserver = new ResizeObserver(entries => {
const size = entries[0].borderBoxSize[0];
const dimensions = {
width: Math.floor(size.inlineSize),
height: Math.floor(size.blockSize),
const boxSize = entries[0].borderBoxSize[0];
const size = {
width: Math.floor(boxSize.inlineSize) + widthOffset,
height: Math.floor(boxSize.blockSize) + heightOffset,
};
if (
this._chartDimensions.height === dimensions.height &&
this._chartDimensions.width === dimensions.width
) {
return;
}
this._chartDimensions = dimensions;
if (idleRequest != null) {
cancelIdleCallback(idleRequest);
idleRequest = undefined;
}
idleRequest = requestIdleCallback(fn, { timeout: 1000 });
this._size = size;
requestAnimationFrame(fn);
});
this.$container = document.querySelector(selector)!.parentElement!;
const rect = this.$container.getBoundingClientRect();
this._chartDimensions = { height: Math.floor(rect.height), width: Math.floor(rect.width) };
this._size = {
height: Math.floor(rect.height) + widthOffset,
width: Math.floor(rect.width) + heightOffset,
};
this._resizeObserver.observe(this.$container);
this._resizeObserver.observe(this.$container, { box: 'border-box' });
}
dispose(): void {
this._resizeObserver.disconnect();
this._chart?.destroy();
}
reset() {
@ -83,6 +83,28 @@ export class TimelineChart {
this._chart?.unzoom();
}
private setEmptyState(dataset: Commit[] | undefined, state: State) {
const $empty = document.getElementById('empty')!;
const $header = document.getElementById('header')!;
if (state.uri != null) {
if (dataset?.length === 0) {
$empty.innerHTML = '<p>No commits found for the specified time period.</p>';
$empty.removeAttribute('hidden');
} else {
$empty.setAttribute('hidden', '');
}
$header.removeAttribute('hidden');
} else if (dataset == null) {
$empty.innerHTML = '<p>There are no editors open that can provide file history information.</p>';
$empty.removeAttribute('hidden');
$header.setAttribute('hidden', '');
} else {
$empty.setAttribute('hidden', '');
$header.removeAttribute('hidden');
}
}
updateChart(state: State) {
this._dateFormat = state.dateFormat;
this._shortDateFormat = state.shortDateFormat;
@ -91,22 +113,19 @@ export class TimelineChart {
this._authorsByIndex.clear();
this._indexByAuthors.clear();
if (state?.dataset == null || state.dataset.length === 0) {
let dataset = state?.dataset;
if (dataset == null && !state.access.allowed && this.placement === 'editor') {
dataset = generateRandomTimelineDataset();
}
this.setEmptyState(dataset, state);
if (dataset == null || dataset.length === 0) {
this._chart?.destroy();
this._chart = undefined;
const $overlay = document.getElementById('chart-empty-overlay') as HTMLDivElement;
$overlay?.classList.toggle('hidden', false);
const $emptyMessage = $overlay.querySelector<HTMLHeadingElement>('[data-bind="empty"]')!;
$emptyMessage.textContent = state.emptyMessage ?? '';
return;
}
const $overlay = document.getElementById('chart-empty-overlay') as HTMLDivElement;
$overlay?.classList.toggle('hidden', true);
const xs: { [key: string]: string } = {};
const colors: { [key: string]: string } = {};
const names: { [key: string]: string } = {};
@ -128,7 +147,7 @@ export class TimelineChart {
// let minChanges = Infinity;
// let maxChanges = -Infinity;
// for (const commit of state.dataset) {
// for (const commit of dataset) {
// const changes = commit.additions + commit.deletions;
// if (changes < minChanges) {
// minChanges = changes;
@ -140,7 +159,7 @@ export class TimelineChart {
// const bubbleScale = scaleSqrt([minChanges, maxChanges], [6, 100]);
for (commit of state.dataset) {
for (commit of dataset) {
({ author, date, additions, deletions } = commit);
if (!this._indexByAuthors.has(author)) {
@ -211,48 +230,52 @@ export class TimelineChart {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
const columns = Object.entries(series).map(([key, value]) => [key, ...value]);
if (this._chart == null) {
const options = this.getChartOptions();
if (options.axis == null) {
options.axis = { y: { tick: {} } };
try {
if (this._chart == null) {
const options = this.getChartOptions();
if (options.axis == null) {
options.axis = { y: { tick: {} } };
}
if (options.axis.y == null) {
options.axis.y = { tick: {} };
}
if (options.axis.y.tick == null) {
options.axis.y.tick = {};
}
options.axis.y.min = index - 2;
options.axis.y.tick.values = [...this._authorsByIndex.keys()];
options.data = {
...options.data,
axes: axes,
colors: colors,
columns: columns,
groups: groups,
names: names,
types: types,
xs: xs,
};
this._chart = bb.generate(options);
} else {
this._chart.config('axis.y.tick.values', [...this._authorsByIndex.keys()], false);
this._chart.config('axis.y.min', index - 2, false);
this._chart.groups(groups);
this._chart.load({
axes: axes,
colors: colors,
columns: columns,
names: names,
types: types,
xs: xs,
unload: true,
});
}
if (options.axis.y == null) {
options.axis.y = { tick: {} };
}
if (options.axis.y.tick == null) {
options.axis.y.tick = {};
}
options.axis.y.min = index - 2;
options.axis.y.tick.values = [...this._authorsByIndex.keys()];
options.data = {
...options.data,
axes: axes,
colors: colors,
columns: columns,
groups: groups,
names: names,
types: types,
xs: xs,
};
this._chart = bb.generate(options);
} else {
this._chart.config('axis.y.tick.values', [...this._authorsByIndex.keys()], false);
this._chart.config('axis.y.min', index - 2, false);
this._chart.groups(groups);
this._chart.load({
axes: axes,
colors: colors,
columns: columns,
names: names,
types: types,
xs: xs,
unload: true,
});
} catch (ex) {
debugger;
}
}
@ -275,16 +298,20 @@ export class TimelineChart {
type: 'timeseries',
clipPath: false,
localtime: true,
show: true,
tick: {
// autorotate: true,
centered: true,
culling: false,
fit: false,
format: (x: number | Date) =>
typeof x === 'number' ? x : formatDate(x, this._shortDateFormat ?? 'short'),
this.compact
? ''
: typeof x === 'number'
? x
: formatDate(x, this._shortDateFormat ?? 'short'),
multiline: false,
// rotate: 15,
show: false,
outer: !this.compact,
},
},
y: {
@ -295,22 +322,30 @@ export class TimelineChart {
},
show: true,
tick: {
format: (y: number) => this._authorsByIndex.get(y) ?? '',
outer: false,
format: (y: number) => (this.compact ? '' : this._authorsByIndex.get(y) ?? ''),
outer: !this.compact,
show: this.compact,
},
},
y2: {
label: {
text: 'Lines changed',
position: 'outer-middle',
},
padding: this.compact
? {
top: 0,
bottom: 0,
}
: undefined,
label: this.compact
? undefined
: {
text: 'Lines changed',
position: 'outer-middle',
},
// min: 0,
show: true,
// tick: {
// outer: true,
// // culling: true,
// // stepSize: 1,
// },
tick: {
format: (y: number) => (this.compact ? '' : y),
outer: !this.compact,
},
},
},
bar: {
@ -340,16 +375,14 @@ export class TimelineChart {
},
},
legend: {
show: true,
show: !this.compact,
// hide: this.compact ? [...this._authorsByIndex.values()] : undefined,
padding: 10,
},
resize: {
auto: false,
},
size: {
height: this._chartDimensions.height - 10,
width: this._chartDimensions.width,
},
size: this._size,
tooltip: {
grouped: true,
format: {
@ -436,3 +469,27 @@ export class TimelineChart {
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
function generateRandomTimelineDataset(): Commit[] {
const dataset: Commit[] = [];
const authors = ['Eric Amodio', 'Justin Roberts', 'Keith Daulton', 'Ramin Tadayon', 'Ada Lovelace', 'Grace Hopper'];
const count = 10;
for (let i = 0; i < count; i++) {
// Generate a random date between now and 3 months ago
const date = new Date(new Date().getTime() - Math.floor(Math.random() * (3 * 30 * 24 * 60 * 60 * 1000)));
dataset.push({
commit: String(i),
author: authors[Math.floor(Math.random() * authors.length)],
date: date.toISOString(),
message: '',
// Generate random additions/deletions between 1 and 20, but ensure we have a tiny and large commit
additions: i === 0 ? 2 : i === count - 1 ? 50 : Math.floor(Math.random() * 20) + 1,
deletions: i === 0 ? 1 : i === count - 1 ? 25 : Math.floor(Math.random() * 20) + 1,
sort: date.getTime(),
});
}
return dataset.sort((a, b) => b.sort - a.sort);
}

+ 0
- 9
src/webviews/apps/plus/timeline/partials/state.free-preview-trial-expired.html View File

@ -1,9 +0,0 @@
<template id="state:free-preview-trial-expired">
<section>
<vscode-button data-action="command:gitlens.plus.loginOrSignUp">Extend Pro Trial</vscode-button>
<p>
Your free 3-day GitLens Pro trial has ended, extend your trial to get an additional free 7-days of the
Visual File History and other <a href="command:gitlens.plus.learn">GitLens+ features</a> on private repos.
</p>
</section>
</template>

+ 0
- 12
src/webviews/apps/plus/timeline/partials/state.free.html View File

@ -1,12 +0,0 @@
<template id="state:free">
<section>
<vscode-button data-action="command:gitlens.plus.startPreviewTrial"
>Try the Visual File History on private repos</vscode-button
>
<p>
To use the Visual File History and other <a href="command:gitlens.plus.learn">GitLens+ features</a> on
private repos, start a free trial of GitLens Pro, without an account, or
<a href="command:gitlens.plus.loginOrSignUp">sign in</a>.
</p>
</section>
</template>

+ 0
- 9
src/webviews/apps/plus/timeline/partials/state.plus-trial-expired.html View File

@ -1,9 +0,0 @@
<template id="state:plus-trial-expired">
<section>
<vscode-button data-action="command:gitlens.plus.purchase">Upgrade to Pro</vscode-button>
<p>
Your GitLens Pro trial has ended, please upgrade to GitLens Pro to continue to use the Visual File History
and other <a href="command:gitlens.plus.learn">GitLens+ features</a> on private repos.
</p>
</section>
</template>

+ 0
- 8
src/webviews/apps/plus/timeline/partials/state.verify-email.html View File

@ -1,8 +0,0 @@
<template id="state:verify-email">
<section>
<h3>Please verify your email</h3>
<p>To use the Visual File History, please verify your email address.</p>
<vscode-button data-action="command:gitlens.plus.resendVerification">Resend verification email</vscode-button>
<vscode-button data-action="command:gitlens.plus.validate">Refresh verification status</vscode-button>
</section>
</template>

+ 22
- 34
src/webviews/apps/plus/timeline/timeline.html View File

@ -10,12 +10,20 @@
</head>
<body class="preload" data-placement="#{placement}">
<plus-feature-welcome class="scrollable">
<p>
Visualize the evolution of a file, including when changes were made, how large they were, and who made
them.
</p>
</plus-feature-welcome>
<div class="container">
<progress-indicator id="spinner" position="top" active="true"></progress-indicator>
<section class="header">
<h2 data-bind="title"></h2>
<h2 data-bind="sha"></h2>
<h2 data-bind="description"></h2>
<section id="header" class="header">
<div class="header--context">
<h2 data-bind="title"></h2>
<h2 data-bind="sha"></h2>
<h2 data-bind="description"></h2>
</div>
<div class="toolbox">
<div class="select-container">
<label for="periods">Timeframe</label>
@ -31,40 +39,26 @@
<vscode-option value="all">Full history</vscode-option>
</vscode-dropdown>
</div>
<gk-button
data-placement-visible="view"
href="command:gitlens.views.timeline.openInTab"
title="Open in Editor Area"
aria-label="Open in Editor Area"
appearance="toolbar"
>
<code-icon icon="link-external"></code-icon>
</gk-button>
</div>
</section>
<section id="content">
<div id="empty" hidden></div>
<div id="chart"></div>
<div id="chart-empty-overlay" class="hidden">
<h1 data-bind="empty"></h1>
</div>
</section>
<section id="footer"></section>
</div>
<div id="overlay" class="hidden">
<div class="modal">
<p>
The Visual File History, a
<a title="Learn more about GitLens+ features" href="command:gitlens.plus.learn"
>✨GitLens+ feature</a
>, allows you to quickly see the evolution of a file, including when changes were made, how large
they were, and who made them.
</p>
<p>
Use it to quickly find when the most impactful changes were made to a file or who best to talk to
about file changes and more.
</p>
<div id="overlay-slot"></div>
</div>
</div>
#{endOfBody}
<style nonce="#{cspNonce}">
:root {
--gl-plus-bg: url(#{webroot}/media/gitlens-backdrop.webp);
}
</style>
<style nonce="#{cspNonce}">
@font-face {
font-family: 'codicon';
font-display: block;
@ -77,10 +71,4 @@
}
</style>
</body>
<!-- prettier-ignore -->
<%= require('html-loader?{"esModule":false}!./partials/state.free.html') %>
<%= require('html-loader?{"esModule":false}!./partials/state.free-preview-trial-expired.html') %>
<%= require('html-loader?{"esModule":false}!./partials/state.plus-trial-expired.html') %>
<%= require('html-loader?{"esModule":false}!./partials/state.verify-email.html') %>
</html>

+ 55
- 121
src/webviews/apps/plus/timeline/timeline.scss View File

@ -1,3 +1,5 @@
@use '../../shared/styles/properties';
* {
box-sizing: border-box;
}
@ -14,11 +16,8 @@ body {
line-height: 1.4;
font-size: 100% !important;
overflow: hidden;
margin: 0 20px 20px 20px;
margin: 0;
padding: 0;
min-width: 400px;
overflow-x: scroll;
}
body[data-placement='editor'] {
@ -57,72 +56,38 @@ h4 {
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:not([appearance='icon']) {
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:not([appearance='icon']) {
align-self: flex-start;
}
span.button-subaction {
align-self: flex-start;
}
}
.header {
display: grid;
grid-template-columns: max-content min-content minmax(min-content, 1fr) max-content;
grid-template-columns: 1fr min-content;
align-items: baseline;
grid-template-areas: 'title sha description toolbox';
justify-content: start;
margin-bottom: 1rem;
@media all and (max-width: 500px) {
grid-template-areas:
'title sha description'
'empty toolbox';
grid-template-columns: max-content min-content minmax(min-content, 1fr);
grid-template-areas: 'context toolbox';
margin: 1rem;
body[data-placement='editor'] & {
margin-top: 0;
}
h2[data-bind='title'] {
grid-area: title;
margin-bottom: 0;
&--context {
grid-area: context;
display: grid;
grid-template-columns: minmax(0, min-content) minmax(0, min-content) minmax(0, 1fr);
gap: 0.5rem;
align-items: baseline;
h2 {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
body:not([data-placement='editor']) & {
display: none;
}
}
h2[data-bind='sha'] {
grid-area: sha;
font-size: 1.3em;
font-weight: 200;
margin-left: 1.5rem;
opacity: 0.7;
white-space: nowrap;
@ -132,17 +97,16 @@ span.button-subaction {
}
h2[data-bind='description'] {
grid-area: description;
font-size: 1.3em;
font-weight: 200;
margin-left: 1.5rem;
margin-left: 0.5rem;
opacity: 0.7;
overflow-wrap: anywhere;
}
.toolbox {
grid-area: toolbox;
display: flex;
gap: 0.3rem;
}
}
@ -160,30 +124,32 @@ span.button-subaction {
#content {
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
}
#chart {
position: absolute !important;
height: 100%;
width: 100%;
overflow: hidden;
}
#chart-empty-overlay {
display: flex;
align-items: center;
justify-content: center;
body:not([data-placement='editor']) & {
left: -16px;
}
}
#empty {
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 60vh;
bottom: 0;
right: 0;
padding: 0.4rem 2rem 1.3rem 2rem;
h1 {
font-size: var(--font-size);
font-weight: 400;
font-size: var(--font-size);
p {
margin-top: 0;
}
}
@ -191,60 +157,28 @@ span.button-subaction {
display: none;
}
#overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
font-size: 1.3em;
min-height: 100%;
padding: 0 2rem 2rem 2rem;
backdrop-filter: blur(3px) saturate(0.8);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
body[data-placement='editor'] {
[data-placement-hidden='editor'],
[data-placement-visible]:not([data-placement-visible='editor']) {
display: none !important;
}
}
.modal {
max-width: 600px;
background: var(--color-hover-background) no-repeat left top;
background-image: var(--gl-plus-bg);
border: 1px solid var(--color-hover-border);
border-radius: 0.4rem;
margin: 1rem;
padding: 1.2rem;
> p:first-child {
margin-top: 0;
body[data-placement='view'] {
[data-placement-hidden='view'],
[data-placement-visible]:not([data-placement-visible='view']) {
display: none !important;
}
}
vscode-button:not([appearance='icon']) {
align-self: center !important;
body:not([data-placement='editor']) {
.bb-tooltip-container {
padding-left: 16px;
}
}
.hidden {
[hidden] {
display: none !important;
}
@import './chart';
@import '../../shared/codicons.scss';
@import '../../shared/glicons.scss';
.glicon {
vertical-align: middle;
}
.glicon,
.codicon {
position: relative;
top: -1px;
}
.mt-tight {
margin-top: 0.3rem;
}

+ 26
- 67
src/webviews/apps/plus/timeline/timeline.ts View File

@ -1,22 +1,23 @@
/*global*/
import './timeline.scss';
import { provideVSCodeDesignSystem, vsCodeButton, vsCodeDropdown, vsCodeOption } from '@vscode/webview-ui-toolkit';
import { GlyphChars } from '../../../../constants';
import { provideVSCodeDesignSystem, vsCodeDropdown, vsCodeOption } from '@vscode/webview-ui-toolkit';
import type { Period, State } from '../../../../plus/webviews/timeline/protocol';
import {
DidChangeNotificationType,
OpenDataPointCommandType,
UpdatePeriodCommandType,
} from '../../../../plus/webviews/timeline/protocol';
import { SubscriptionPlanId, SubscriptionState } from '../../../../subscription';
import type { IpcMessage } from '../../../protocol';
import { ExecuteCommandType, onIpc } from '../../../protocol';
import { onIpc } from '../../../protocol';
import { App } from '../../shared/appBase';
import { DOM } from '../../shared/dom';
import type { PlusFeatureWelcome } from '../shared/components/plus-feature-welcome';
import type { DataPointClickEvent } from './chart';
import { TimelineChart } from './chart';
import '../../shared/components/code-icon';
import '../../shared/components/progress';
import '../../shared/components/button';
import '../shared/components/plus-feature-welcome';
export class TimelineApp extends App<State> {
private _chart: TimelineChart | undefined;
@ -26,7 +27,7 @@ export class TimelineApp extends App {
}
protected override onInitialize() {
provideVSCodeDesignSystem().register(vsCodeButton(), vsCodeDropdown(), vsCodeOption());
provideVSCodeDesignSystem().register(vsCodeDropdown(), vsCodeOption());
this.updateState();
}
@ -35,11 +36,11 @@ export class TimelineApp extends App {
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),
),
{ dispose: () => this._chart?.dispose() },
);
return disposables;
@ -64,17 +65,6 @@ export class TimelineApp extends App {
}
}
protected override setState(state: Partial<State>) {
super.setState({ period: state.period, uri: state.uri });
}
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);
}
@ -96,69 +86,39 @@ export class TimelineApp extends App {
}
private updateState(): void {
const $overlay = document.getElementById('overlay') as HTMLDivElement;
$overlay.classList.toggle('hidden', this.state.access.allowed === true);
const $slot = document.getElementById('overlay-slot') as HTMLDivElement;
if (this.state.access.allowed === false) {
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.FreePreviewTrialExpired:
DOM.insertTemplate('state:free-preview-trial-expired', $slot, options);
break;
case SubscriptionState.FreePlusTrialExpired:
DOM.insertTemplate('state:plus-trial-expired', $slot, options);
break;
}
if (this.state.dataset == null) return;
} else {
$slot.innerHTML = '';
const $welcome = document.getElementsByTagName('plus-feature-welcome')?.[0] as PlusFeatureWelcome;
if ($welcome != null) {
$welcome.state = this.state.access.subscription.current.state;
$welcome.allowed = this.state.access.allowed === true || this.state.uri == null;
}
if (this._chart == null) {
this._chart = new TimelineChart('#chart');
this._chart = new TimelineChart('#chart', this.placement);
this._chart.onDidClickDataPoint(this.onChartDataPointClicked, this);
}
let { title, sha } = this.state;
let description = '';
const index = title.lastIndexOf('/');
if (index >= 0) {
const name = title.substring(index + 1);
description = title.substring(0, index);
title = name;
if (title != null) {
const index = title.lastIndexOf('/');
if (index >= 0) {
const name = title.substring(index + 1);
description = title.substring(0, index);
title = name;
}
} else if (this.placement === 'editor' && this.state.dataset == null && !this.state.access.allowed) {
title = 'index.ts';
description = 'src/app';
}
function updateBoundData(
key: string,
value: string | undefined,
options?: { hideIfEmpty?: boolean; html?: boolean },
) {
function updateBoundData(key: string, value: string | undefined, options?: { html?: boolean }) {
const $el = document.querySelector(`[data-bind="${key}"]`);
if ($el != null) {
const empty = value == null || value.length === 0;
if (options?.hideIfEmpty) {
$el.classList.toggle('hidden', empty);
}
if (options?.html && !empty) {
$el.innerHTML = value;
if (options?.html) {
$el.innerHTML = value ?? '';
} else {
$el.textContent = String(value) || GlyphChars.Space;
$el.textContent = value ?? '';
}
}
}
@ -171,7 +131,6 @@ export class TimelineApp extends App {
? /*html*/ `<code-icon icon="git-commit" size="16"></code-icon><span class="sha">${sha}</span>`
: undefined,
{
hideIfEmpty: true,
html: true,
},
);

+ 106
- 0
src/webviews/apps/shared/components/button.ts View File

@ -0,0 +1,106 @@
import { css, html, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { focusOutline } from './styles/lit/a11y.css';
import { elementBase } from './styles/lit/base.css';
@customElement('gk-button')
export class GKButton extends LitElement {
static override styles = [
elementBase,
css`
:host {
--button-foreground: var(--vscode-button-foreground);
--button-background: var(--vscode-button-background);
--button-hover-background: var(--vscode-button-hoverBackground);
--button-padding: 0.4rem 1.1rem;
--button-border: var(--vscode-button-border, transparent);
display: inline-block;
border: none;
font-family: inherit;
font-size: inherit;
line-height: 1.694;
text-align: center;
text-decoration: none;
user-select: none;
background: var(--button-background);
color: var(--button-foreground);
cursor: pointer;
border: 1px solid var(--button-border);
border-radius: var(--gk-action-radius);
}
:host(:not([href])) {
padding: var(--button-padding);
}
:host([href]) > a {
display: inline-block;
padding: var(--button-padding);
color: inherit;
text-decoration: none;
width: 100%;
height: 100%;
}
:host(:hover) {
background: var(--button-hover-background);
}
:host(:focus) {
${focusOutline}
}
:host([full]) {
width: 100%;
}
:host([appearance='secondary']) {
--button-background: var(--vscode-button-secondaryBackground);
--button-foreground: var(--vscode-button-secondaryForeground);
--button-hover-background: var(--vscode-button-secondaryHoverBackground);
}
:host([appearance='toolbar']) {
--button-background: transparent;
--button-foreground: var(--vscode-foreground);
--button-hover-background: var(--vscode-toolbar-hoverBackground);
--button-padding: 0.45rem 0.4rem 0.14rem 0.4rem;
line-height: 1.64;
}
:host([appearance='alert']) {
--button-background: transparent;
--button-border: var(--color-alert-infoBorder);
--button-foreground: var(--color-button-foreground);
--button-hover-background: var(--color-alert-infoBorder);
--button-padding: 0.4rem;
line-height: 1.64;
}
`,
];
@property({ type: Boolean, reflect: true })
full = false;
@property()
href?: string;
@property({ reflect: true })
override get role() {
return this.href ? 'link' : 'button';
}
@property()
appearance?: string;
@property({ type: Number, reflect: true })
override tabIndex = 0;
override render() {
const main = html`<slot></slot>`;
return this.href != null ? html`<a href=${this.href}>${main}</a>` : main;
}
}

+ 1632
- 1537
src/webviews/apps/shared/components/code-icon.ts
File diff suppressed because it is too large
View File


+ 19
- 0
src/webviews/apps/shared/components/styles/lit/a11y.css.ts View File

@ -0,0 +1,19 @@
import { css } from 'lit';
export const srOnly = css`
.sr-only,
.sr-only-focusable:not(:active):not(:focus) {
clip: rect(0 0 0 0);
clip-path: inset(50%);
width: 1px;
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
}
`;
export const focusOutline = css`
outline: 1px solid var(--color-focus-border);
outline-offset: -1px;
`;

+ 15
- 0
src/webviews/apps/shared/components/styles/lit/base.css.ts View File

@ -0,0 +1,15 @@
import { css } from 'lit';
export const elementBase = css`
:host {
box-sizing: border-box;
}
:host *,
:host *::before,
:host *::after {
box-sizing: inherit;
}
[hidden] {
display: none !important;
}
`;

+ 17
- 0
src/webviews/apps/shared/styles/properties.scss View File

@ -0,0 +1,17 @@
:root {
--gitlens-gutter-width: 20px;
--gk-action-radius: 0.2rem;
--gk-card-radius: 0.4rem;
}
.vscode-high-contrast,
.vscode-dark {
--gk-card-background: var(--color-background--lighten-05);
--gk-card-hover-background: var(--color-background--lighten-075);
}
.vscode-high-contrast-light,
.vscode-light {
--gk-card-background: var(--color-background--darken-05);
--gk-card-hover-background: var(--color-background--darken-075);
}

||||||
x
 
000:0
Loading…
Cancel
Save