537 lines
17 KiB

import GraphContainer, {
type CssVariables,
type GraphColumnSetting as GKGraphColumnSetting,
type GraphColumnsSettings as GKGraphColumnsSettings,
type GraphPlatform,
type GraphRow,
type GraphZoneType,
} from '@gitkraken/gitkraken-components';
import type { ReactElement } from 'react';
import React, { createElement, useEffect, useRef, useState } from 'react';
import { getPlatform } from '@env/platform';
import type { GraphColumnConfig } from '../../../../config';
import { RepositoryVisibility } from '../../../../git/gitProvider';
import type { GitGraphRowType } from '../../../../git/models/graph';
import type {
CommitListCallback,
DismissBannerParams,
GraphCompositeConfig,
GraphRepository,
State,
} from '../../../../plus/webviews/graph/protocol';
import type { Subscription } from '../../../../subscription';
import { getSubscriptionTimeRemaining, SubscriptionState } from '../../../../subscription';
import { pluralize } from '../../../../system/string';
export interface GraphWrapperProps extends State {
nonce?: string;
subscriber: (callback: CommitListCallback) => () => void;
onSelectRepository?: (repository: GraphRepository) => void;
onColumnChange?: (name: string, settings: GraphColumnConfig) => void;
onMoreCommits?: (limit?: number) => void;
onDismissBanner?: (key: DismissBannerParams['key']) => void;
onSelectionChange?: (selection: { id: string; type: GitGraphRowType }[]) => void;
}
const getStyleProps = (
mixedColumnColors: CssVariables | undefined,
): { cssVariables: CssVariables; themeOpacityFactor: number } => {
const body = document.body;
const computedStyle = window.getComputedStyle(body);
return {
cssVariables: {
'--app__bg0': computedStyle.getPropertyValue('--color-background'),
'--panel__bg0': computedStyle.getPropertyValue('--graph-panel-bg'),
'--text-selected': computedStyle.getPropertyValue('--color-foreground'),
'--text-normal': computedStyle.getPropertyValue('--color-foreground--85'),
'--text-secondary': computedStyle.getPropertyValue('--color-foreground--65'),
'--text-disabled': computedStyle.getPropertyValue('--color-foreground--50'),
'--text-accent': computedStyle.getPropertyValue('--color-link-foreground'),
'--text-inverse': computedStyle.getPropertyValue('--vscode-input-background'),
'--text-bright': computedStyle.getPropertyValue('--vscode-input-background'),
...mixedColumnColors,
},
themeOpacityFactor: parseInt(computedStyle.getPropertyValue('--graph-theme-opacity-factor')) || 1,
};
};
const defaultGraphColumnsSettings: GKGraphColumnsSettings = {
commitAuthorZone: { width: 110 },
commitDateTimeZone: { width: 130 },
commitMessageZone: { width: 130 },
commitZone: { width: 170 },
refZone: { width: 150 },
};
const getGraphColSettingsModel = (config?: GraphCompositeConfig): GKGraphColumnsSettings => {
const columnsSettings: GKGraphColumnsSettings = { ...defaultGraphColumnsSettings };
if (config?.columns !== undefined) {
for (const column of Object.keys(config.columns)) {
columnsSettings[column] = {
width: config.columns[column].width,
};
}
}
return columnsSettings;
};
type DebouncableFn = (...args: any) => void;
type DebouncedFn = (...args: any) => void;
const debounceFrame = (func: DebouncableFn): DebouncedFn => {
let timer: number;
return function (...args: any) {
if (timer) cancelAnimationFrame(timer);
timer = requestAnimationFrame(() => {
func(...args);
});
};
};
const createIconElements = (): { [key: string]: ReactElement<any> } => {
const iconList = [
'head',
'remote',
'tag',
'stash',
'check',
'loading',
'warning',
'added',
'modified',
'deleted',
'renamed',
'resolved',
];
const elementLibrary: { [key: string]: ReactElement<any> } = {};
iconList.forEach(iconKey => {
elementLibrary[iconKey] = createElement('span', { className: `graph-icon icon--${iconKey}` });
});
return elementLibrary;
};
const iconElementLibrary = createIconElements();
const getIconElementLibrary = (iconKey: string) => {
return iconElementLibrary[iconKey];
};
const getClientPlatform = (): GraphPlatform => {
switch (getPlatform()) {
case 'web-macOS':
return 'darwin';
case 'web-windows':
return 'win32';
case 'web-linux':
default:
return 'linux';
}
};
const clientPlatform = getClientPlatform();
// eslint-disable-next-line @typescript-eslint/naming-convention
export function GraphWrapper({
subscriber,
repositories = [],
rows = [],
selectedRepository,
selectedRows,
subscription,
selectedVisibility,
allowed,
config,
paging,
onSelectRepository,
onColumnChange,
onMoreCommits,
onSelectionChange,
nonce,
mixedColumnColors,
previewBanner = true,
trialBanner = true,
onDismissBanner,
}: GraphWrapperProps) {
const [graphList, setGraphList] = useState(rows);
const [reposList, setReposList] = useState(repositories);
const [currentRepository, setCurrentRepository] = useState<GraphRepository | undefined>(
reposList.find(item => item.path === selectedRepository),
);
const [graphSelectedRows, setSelectedRows] = useState(selectedRows);
const [graphColSettings, setGraphColSettings] = useState(getGraphColSettingsModel(config));
const [pagingState, setPagingState] = useState(paging);
const [isLoading, setIsLoading] = useState(false);
const [styleProps, setStyleProps] = useState(getStyleProps(mixedColumnColors));
// TODO: application shouldn't know about the graph component's header
const graphHeaderOffset = 24;
const [mainWidth, setMainWidth] = useState<number>();
const [mainHeight, setMainHeight] = useState<number>();
const mainRef = useRef<HTMLElement>(null);
// banner
const [showPreview, setShowPreview] = useState(previewBanner);
// account
const [showAccount, setShowAccount] = useState(trialBanner);
const [isAllowed, setIsAllowed] = useState(allowed ?? false);
const [isPrivateRepo, setIsPrivateRepo] = useState(selectedVisibility === RepositoryVisibility.Private);
const [subscriptionSnapshot, setSubscriptionSnapshot] = useState<Subscription | undefined>(subscription);
// repo selection UI
const [repoExpanded, setRepoExpanded] = useState(false);
useEffect(() => {
if (mainRef.current === null) {
return;
}
const setDimensionsDebounced = debounceFrame((width, height) => {
setMainWidth(Math.floor(width));
setMainHeight(Math.floor(height) - graphHeaderOffset);
});
const resizeObserver = new ResizeObserver(entries => {
entries.forEach(entry => {
setDimensionsDebounced(entry.contentRect.width, entry.contentRect.height);
});
});
resizeObserver.observe(mainRef.current);
return () => {
resizeObserver.disconnect();
};
}, [mainRef]);
function transformData(state: State) {
setGraphList(state.rows ?? []);
setReposList(state.repositories ?? []);
setCurrentRepository(reposList.find(item => item.path === state.selectedRepository));
setSelectedRows(state.selectedRows);
setGraphColSettings(getGraphColSettingsModel(state.config));
setPagingState(state.paging);
setIsLoading(false);
setStyleProps(getStyleProps(state.mixedColumnColors));
setIsAllowed(state.allowed ?? false);
setSubscriptionSnapshot(state.subscription);
setIsPrivateRepo(state.selectedVisibility === RepositoryVisibility.Private);
}
useEffect(() => {
if (subscriber === undefined) {
return;
}
return subscriber(transformData);
}, []);
const handleSelectRepository = (item: GraphRepository) => {
if (item != null && item !== currentRepository) {
setIsLoading(true);
onSelectRepository?.(item);
}
setRepoExpanded(false);
};
const handleToggleRepos = () => {
if (currentRepository != null && reposList.length <= 1) return;
setRepoExpanded(!repoExpanded);
};
const handleMoreCommits = () => {
setIsLoading(true);
onMoreCommits?.();
};
const handleOnColumnResized = (graphZoneType: GraphZoneType, columnSettings: GKGraphColumnSetting) => {
onColumnChange?.(graphZoneType, { width: columnSettings.width });
};
const handleSelectGraphRows = (graphRows: GraphRow[]) => {
onSelectionChange?.(graphRows.map(r => ({ id: r.sha, type: r.type as GitGraphRowType })));
};
const handleDismissPreview = () => {
setShowPreview(false);
onDismissBanner?.('preview');
};
const handleDismissAccount = () => {
setShowAccount(false);
onDismissBanner?.('trial');
};
const renderTrialDays = () => {
if (
!subscriptionSnapshot ||
![SubscriptionState.FreeInPreviewTrial, SubscriptionState.FreePlusInTrial].includes(
subscriptionSnapshot.state,
)
) {
return;
}
const days = getSubscriptionTimeRemaining(subscriptionSnapshot, 'days') ?? 0;
return (
<span className="mr-loose">
<span className="badge">GitLens+ Trial</span> ({days < 1 ? '< 1 day' : pluralize('day', days)} left)
</span>
);
};
const renderAlertContent = () => {
if (subscriptionSnapshot == null || !isPrivateRepo) return;
let icon = 'account';
let modifier = '';
let content;
let actions;
let days = 0;
if (
[SubscriptionState.FreeInPreviewTrial, SubscriptionState.FreePlusInTrial].includes(
subscriptionSnapshot.state,
)
) {
days = getSubscriptionTimeRemaining(subscriptionSnapshot, 'days') ?? 0;
}
switch (subscriptionSnapshot.state) {
case SubscriptionState.Free:
case SubscriptionState.Paid:
return;
case SubscriptionState.FreeInPreviewTrial:
case SubscriptionState.FreePlusInTrial:
icon = 'calendar';
modifier = 'neutral';
content = (
<>
<p className="alert__title">GitLens+ Trial</p>
<p className="alert__message">
You have {days < 1 ? 'less than one day' : pluralize('day', days)} left in your{' '}
<a title="Learn more about GitLens+ features" href="command:gitlens.plus.learn">
GitLens+ trial
</a>
. Once your trial ends, you'll need a paid plan to continue to use GitLens+ features,
including the Commit Graph, on this and other private repos.
</p>
</>
);
break;
case SubscriptionState.FreePreviewTrialExpired:
icon = 'warning';
modifier = 'warning';
content = (
<>
<p className="alert__title">Extend Your GitLens+ Trial</p>
<p className="alert__message">
Your free trial has ended, please sign in to extend your trial of GitLens+ features on
private repos by an additional 7-days.
</p>
</>
);
actions = (
<a className="alert-action" href="command:gitlens.plus.loginOrSignUp">
Extend Trial
</a>
);
break;
case SubscriptionState.FreePlusTrialExpired:
icon = 'warning';
modifier = 'warning';
content = (
<>
<p className="alert__title">GitLens+ Trial Expired</p>
<p className="alert__message">
Your free trial has ended, please upgrade your account to continue to use GitLens+ features,
including the Commit Graph, on this and other private repos.
</p>
</>
);
actions = (
<a className="alert-action" href="command:gitlens.plus.purchase">
Upgrade Your Account
</a>
);
break;
case SubscriptionState.VerificationRequired:
icon = 'unverified';
modifier = 'warning';
content = (
<>
<p className="alert__title">Please verify your email</p>
<p className="alert__message">Please verify the email for the account you created.</p>
</>
);
actions = (
<>
<a className="alert-action" href="command:gitlens.plus.resendVerification">
Resend Verification Email
</a>
<a className="alert-action" href="command:gitlens.plus.validate">
Refresh Verification Status
</a>
</>
);
break;
}
return (
<div className={`alert${modifier !== '' ? ` alert--${modifier}` : ''}`}>
<span className={`alert__icon codicon codicon-${icon}`}></span>
<div className="alert__content">
{content}
{actions && <div className="alert__actions">{actions}</div>}
</div>
{isAllowed && (
<button className="alert__dismiss" type="button" onClick={() => handleDismissAccount()}>
<span className="codicon codicon-chrome-close"></span>
</button>
)}
</div>
);
};
return (
<>
<section className="graph-app__banners">
{showPreview && (
<div className="alert">
<span className="alert__icon codicon codicon-eye"></span>
<div className="alert__content">
<p className="alert__title">GitLens+ Feature Preview</p>
<p className="alert__message">
The Commit Graph is currently in preview. It will always be freely available for local
and public repos, while private repos require a paid plan.
</p>
<p className="alert__accent">
<span className="codicon codicon-feedback alert__accent-icon" /> Join the{' '}
<a href="https://github.com/gitkraken/vscode-gitlens/discussions/2158">
discussion on GitHub
</a>
! We'd love to hear from you.
</p>
<p className="alert__accent">
<span className="glicon glicon-clock alert__accent-icon" /> GitLens+{' '}
<a href="command:gitlens.plus.purchase">introductory pricing</a> will end with the next
release (late Sept, early Oct).
</p>
</div>
<button className="alert__dismiss" type="button" onClick={() => handleDismissPreview()}>
<span className="codicon codicon-chrome-close"></span>
</button>
</div>
)}
{showAccount && renderAlertContent()}
</section>
<main
ref={mainRef}
id="main"
className={`graph-app__main${!isAllowed ? ' is-gated' : ''}`}
aria-hidden={!isAllowed}
>
{!isAllowed && <div className="graph-app__cover"></div>}
{currentRepository !== undefined ? (
<>
{mainWidth !== undefined && mainHeight !== undefined && (
<GraphContainer
columnsSettings={graphColSettings}
cssVariables={styleProps.cssVariables}
getExternalIcon={getIconElementLibrary}
graphRows={graphList}
height={mainHeight}
isSelectedBySha={graphSelectedRows}
hasMoreCommits={pagingState?.more}
isLoadingRows={isLoading}
nonce={nonce}
onColumnResized={handleOnColumnResized}
onSelectGraphRows={handleSelectGraphRows}
onShowMoreCommits={handleMoreCommits}
platform={clientPlatform}
width={mainWidth}
themeOpacityFactor={styleProps.themeOpacityFactor}
/>
)}
</>
) : (
<p>No repository is selected</p>
)}
</main>
<footer className={`actionbar graph-app__footer${!isAllowed ? ' is-gated' : ''}`} aria-hidden={!isAllowed}>
<div className="actionbar__group">
<div className="actioncombo">
<button
type="button"
aria-controls="repo-actioncombo-list"
aria-expanded={repoExpanded}
aria-haspopup="listbox"
id="repo-actioncombo-label"
className="actioncombo__label"
role="combobox"
aria-activedescendant={
repoExpanded
? `repo-actioncombo-item-${reposList.findIndex(
item => item.path === currentRepository?.path,
)}`
: undefined
}
onClick={() => handleToggleRepos()}
>
<span className="codicon codicon-repo actioncombo__icon" aria-label="Repository "></span>
{currentRepository?.formattedName ?? 'none selected'}
</button>
<div
className="actioncombo__list"
id="repo-actioncombo-list"
role="listbox"
tabIndex={-1}
aria-labelledby="repo-actioncombo-label"
>
{reposList.length > 0 ? (
reposList.map((item, index) => (
<button
type="button"
className="actioncombo__item"
role="option"
data-value={item.path}
id={`repo-actioncombo-item-${index}`}
key={`repo-actioncombo-item-${index}`}
aria-selected={item.path === currentRepository?.path}
onClick={() => handleSelectRepository(item)}
disabled={item.path === currentRepository?.path}
>
{item.formattedName}
</button>
))
) : (
<span
className="actioncombo__item"
role="option"
id="repo-actioncombo-item-0"
aria-selected="true"
>
None available
</span>
)}
</div>
</div>
{isAllowed && graphList.length > 0 && (
<span className="actionbar__details">
showing {graphList.length} item{graphList.length ? 's' : ''}
</span>
)}
{isLoading && (
<span className="actionbar__loading">
<span className="icon--loading icon-modifier--spin" />
</span>
)}
</div>
<div className="actionbar__group">
{renderTrialDays()}
<span className="badge">Preview</span>
<a
href="https://github.com/gitkraken/vscode-gitlens/discussions/2158"
title="Commit Graph Feedback"
aria-label="Commit Graph Feedback"
>
<span className="codicon codicon-feedback"></span>
</a>
</div>
</footer>
</>
);
}