1584 lines
49 KiB

import type {
GraphColumnMode,
GraphColumnSetting,
GraphColumnsSettings,
GraphContainerProps,
GraphPlatform,
GraphRef,
GraphRefGroup,
GraphRefOptData,
GraphRow,
GraphZoneType,
OnFormatCommitDateTime,
} from '@gitkraken/gitkraken-components';
import GraphContainer, { CommitDateTimeSources, refZone } from '@gitkraken/gitkraken-components';
import { VSCodeCheckbox, VSCodeRadio, VSCodeRadioGroup } from '@vscode/webview-ui-toolkit/react';
import type { FormEvent, ReactElement } from 'react';
import React, { createElement, useEffect, useMemo, useRef, useState } from 'react';
import { getPlatform } from '@env/platform';
import type { DateStyle } from '../../../../config';
import type { SearchQuery } from '../../../../git/search';
import type { Subscription } from '../../../../plus/gk/account/subscription';
import type {
DidEnsureRowParams,
DidSearchParams,
GraphAvatars,
GraphColumnName,
GraphColumnsConfig,
GraphComponentConfig,
GraphExcludedRef,
GraphExcludeTypes,
GraphMinimapMarkerTypes,
GraphMissingRefsMetadata,
GraphRefMetadataItem,
GraphRepository,
GraphSearchResults,
GraphSearchResultsError,
InternalNotificationType,
State,
UpdateGraphConfigurationParams,
UpdateStateCallback,
} from '../../../../plus/webviews/graph/protocol';
import {
DidChangeAvatarsNotificationType,
DidChangeColumnsNotificationType,
DidChangeGraphConfigurationNotificationType,
DidChangeRefsMetadataNotificationType,
DidChangeRefsVisibilityNotificationType,
DidChangeRowsNotificationType,
DidChangeRowsStatsNotificationType,
DidChangeSelectionNotificationType,
DidChangeSubscriptionNotificationType,
DidChangeWindowFocusNotificationType,
DidChangeWorkingTreeNotificationType,
DidFetchNotificationType,
DidSearchNotificationType,
} from '../../../../plus/webviews/graph/protocol';
import { pluralize } from '../../../../system/string';
import { createWebviewCommandLink } from '../../../../system/webview';
import type { IpcNotificationType } from '../../../protocol';
import { MenuDivider, MenuItem, MenuLabel, MenuList } from '../../shared/components/menu/react';
import { PopMenu } from '../../shared/components/overlays/pop-menu/react';
import { PopOver } from '../../shared/components/overlays/react';
import { FeatureGate } from '../../shared/components/react/feature-gate';
import { FeatureGateBadge } from '../../shared/components/react/feature-gate-badge';
import { SearchBox } from '../../shared/components/search/react';
import type { SearchNavigationEventDetail } from '../../shared/components/search/search-box';
import type { DateTimeFormat } from '../../shared/date';
import { formatDate, fromNow } from '../../shared/date';
import type {
GraphMinimapDaySelectedEventDetail,
GraphMinimapMarker,
GraphMinimapSearchResultMarker,
GraphMinimapStats,
GraphMinimap as GraphMinimapType,
StashMarker,
} from './minimap/minimap';
import { GraphMinimap } from './minimap/react';
export interface GraphWrapperProps {
nonce?: string;
state: State;
subscriber: (callback: UpdateStateCallback) => () => void;
onChooseRepository?: () => void;
onColumnsChange?: (colsSettings: GraphColumnsConfig) => void;
onDimMergeCommits?: (dim: boolean) => void;
onDoubleClickRef?: (ref: GraphRef, metadata?: GraphRefMetadataItem) => void;
onDoubleClickRow?: (row: GraphRow, preserveFocus?: boolean) => void;
onMissingAvatars?: (emails: Record<string, string>) => void;
onMissingRefsMetadata?: (metadata: GraphMissingRefsMetadata) => void;
onMoreRows?: (id?: string) => void;
onRefsVisibilityChange?: (refs: GraphExcludedRef[], visible: boolean) => void;
onSearch?: (search: SearchQuery | undefined, options?: { limit?: number }) => void;
onSearchPromise?: (
search: SearchQuery,
options?: { limit?: number; more?: boolean },
) => Promise<DidSearchParams | undefined>;
onSearchOpenInView?: (search: SearchQuery) => void;
onSelectionChange?: (rows: GraphRow[]) => void;
onEnsureRowPromise?: (id: string, select: boolean) => Promise<DidEnsureRowParams | undefined>;
onExcludeType?: (key: keyof GraphExcludeTypes, value: boolean) => void;
onIncludeOnlyRef?: (all: boolean) => void;
onUpdateGraphConfiguration?: (changes: UpdateGraphConfigurationParams['changes']) => void;
}
const getGraphDateFormatter = (config?: GraphComponentConfig): OnFormatCommitDateTime => {
return (commitDateTime: number, source?: CommitDateTimeSources) =>
formatCommitDateTime(commitDateTime, config?.dateStyle, config?.dateFormat, source);
};
const createIconElements = (): Record<string, ReactElement> => {
const iconList = [
'head',
'remote',
'remote-github',
'remote-githubEnterprise',
'remote-gitlab',
'remote-gitlabSelfHosted',
'remote-bitbucket',
'remote-bitbucketServer',
'remote-azureDevops',
'tag',
'stash',
'check',
'loading',
'warning',
'added',
'modified',
'deleted',
'renamed',
'resolved',
'pull-request',
'show',
'hide',
'branch',
'graph',
'commit',
'author',
'datetime',
'message',
'changes',
'files',
];
const miniIconList = ['upstream-ahead', 'upstream-behind'];
const elementLibrary: Record<string, ReactElement> = {};
iconList.forEach(iconKey => {
elementLibrary[iconKey] = createElement('span', { className: `graph-icon icon--${iconKey}` });
});
miniIconList.forEach(iconKey => {
elementLibrary[iconKey] = createElement('span', { className: `graph-icon mini-icon icon--${iconKey}` });
});
//TODO: fix this once the styling is properly configured component-side
elementLibrary.settings = createElement('span', {
className: 'graph-icon icon--settings',
style: { fontSize: '1.1rem', right: '0px', top: '-1px' },
});
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,
nonce,
state,
onChooseRepository,
onColumnsChange,
onDimMergeCommits,
onDoubleClickRef,
onDoubleClickRow,
onEnsureRowPromise,
onMissingAvatars,
onMissingRefsMetadata,
onMoreRows,
onRefsVisibilityChange,
onSearch,
onSearchPromise,
onSearchOpenInView,
onSelectionChange,
onExcludeType,
onIncludeOnlyRef,
onUpdateGraphConfiguration,
}: GraphWrapperProps) {
const graphRef = useRef<GraphContainer>(null);
const [rows, setRows] = useState(state.rows ?? []);
const [rowsStats, setRowsStats] = useState(state.rowsStats);
const [rowsStatsLoading, setRowsStatsLoading] = useState(state.rowsStatsLoading);
const [avatars, setAvatars] = useState(state.avatars);
const [downstreams, setDownstreams] = useState(state.downstreams ?? {});
const [refsMetadata, setRefsMetadata] = useState(state.refsMetadata);
const [repos, setRepos] = useState(state.repositories ?? []);
const [repo, setRepo] = useState<GraphRepository | undefined>(
repos.find(item => item.path === state.selectedRepository),
);
const [branchState, setBranchState] = useState(state.branchState);
const [selectedRows, setSelectedRows] = useState(state.selectedRows);
const [activeRow, setActiveRow] = useState(state.activeRow);
const [activeDay, setActiveDay] = useState(state.activeDay);
const [visibleDays, setVisibleDays] = useState(state.visibleDays);
const [graphConfig, setGraphConfig] = useState(state.config);
// const [graphDateFormatter, setGraphDateFormatter] = useState(getGraphDateFormatter(config));
const [columns, setColumns] = useState(state.columns);
const [excludeRefsById, setExcludeRefsById] = useState(state.excludeRefs);
const [excludeTypes, setExcludeTypes] = useState(state.excludeTypes);
const [includeOnlyRefsById, setIncludeOnlyRefsById] = useState(state.includeOnlyRefs);
const [context, setContext] = useState(state.context);
const [pagingHasMore, setPagingHasMore] = useState(state.paging?.hasMore ?? false);
const [isLoading, setIsLoading] = useState(state.loading);
const [styleProps, setStyleProps] = useState(state.theming);
const [branchName, setBranchName] = useState(state.branchName);
const [lastFetched, setLastFetched] = useState(state.lastFetched);
const [windowFocused, setWindowFocused] = useState(state.windowFocused);
const [allowed, setAllowed] = useState(state.allowed ?? false);
const [subscription, setSubscription] = useState<Subscription | undefined>(state.subscription);
// search state
const searchEl = useRef<any>(null);
const [searchQuery, setSearchQuery] = useState<SearchQuery | undefined>(undefined);
const { results, resultsError } = getSearchResultModel(state);
const [searchResults, setSearchResults] = useState(results);
const [searchResultsError, setSearchResultsError] = useState(resultsError);
const [searchResultsHidden, setSearchResultsHidden] = useState(false);
const [searching, setSearching] = useState(false);
// working tree state
const [workingTreeStats, setWorkingTreeStats] = useState(
state.workingTreeStats ?? { added: 0, modified: 0, deleted: 0 },
);
const minimap = useRef<GraphMinimapType | undefined>(undefined);
const ensuredIds = useRef<Set<string>>(new Set());
const ensuredSkippedIds = useRef<Set<string>>(new Set());
function updateState(
state: State,
type?: IpcNotificationType<any> | InternalNotificationType,
themingChanged?: boolean,
) {
if (themingChanged) {
setStyleProps(state.theming);
}
switch (type) {
case 'didChangeTheme':
if (!themingChanged) {
setStyleProps(state.theming);
}
break;
case DidChangeAvatarsNotificationType:
setAvatars(state.avatars);
break;
case DidChangeWindowFocusNotificationType:
setWindowFocused(state.windowFocused);
break;
case DidChangeRefsMetadataNotificationType:
setRefsMetadata(state.refsMetadata);
break;
case DidChangeColumnsNotificationType:
setColumns(state.columns);
setContext(state.context);
break;
case DidChangeRowsNotificationType:
setRows(state.rows ?? []);
setRowsStats(state.rowsStats);
setRowsStatsLoading(state.rowsStatsLoading);
setSelectedRows(state.selectedRows);
setAvatars(state.avatars);
setDownstreams(state.downstreams ?? {});
setRefsMetadata(state.refsMetadata);
setPagingHasMore(state.paging?.hasMore ?? false);
setIsLoading(state.loading);
break;
case DidChangeRowsStatsNotificationType:
setRowsStats(state.rowsStats);
setRowsStatsLoading(state.rowsStatsLoading);
break;
case DidSearchNotificationType: {
const { results, resultsError } = getSearchResultModel(state);
setSearchResultsError(resultsError);
setSearchResults(results);
setSelectedRows(state.selectedRows);
setSearching(false);
break;
}
case DidChangeGraphConfigurationNotificationType:
setGraphConfig(state.config);
break;
case DidChangeSelectionNotificationType:
setSelectedRows(state.selectedRows);
break;
case DidChangeRefsVisibilityNotificationType:
setExcludeRefsById(state.excludeRefs);
setExcludeTypes(state.excludeTypes);
setIncludeOnlyRefsById(state.includeOnlyRefs);
break;
case DidChangeSubscriptionNotificationType:
setAllowed(state.allowed ?? false);
setSubscription(state.subscription);
break;
case DidChangeWorkingTreeNotificationType:
setWorkingTreeStats(state.workingTreeStats ?? { added: 0, modified: 0, deleted: 0 });
break;
case DidFetchNotificationType:
setLastFetched(state.lastFetched);
break;
default: {
setAllowed(state.allowed ?? false);
if (!themingChanged) {
setStyleProps(state.theming);
}
setBranchName(state.branchName);
setLastFetched(state.lastFetched);
setColumns(state.columns);
setRows(state.rows ?? []);
setRowsStats(state.rowsStats);
setRowsStatsLoading(state.rowsStatsLoading);
setWorkingTreeStats(state.workingTreeStats ?? { added: 0, modified: 0, deleted: 0 });
setGraphConfig(state.config);
setSelectedRows(state.selectedRows);
setExcludeRefsById(state.excludeRefs);
setExcludeTypes(state.excludeTypes);
setIncludeOnlyRefsById(state.includeOnlyRefs);
setContext(state.context);
setAvatars(state.avatars ?? {});
setDownstreams(state.downstreams ?? {});
setBranchState(state.branchState);
setRefsMetadata(state.refsMetadata);
setPagingHasMore(state.paging?.hasMore ?? false);
setRepos(state.repositories ?? []);
setRepo(repos.find(item => item.path === state.selectedRepository));
// setGraphDateFormatter(getGraphDateFormatter(config));
setSubscription(state.subscription);
const { results, resultsError } = getSearchResultModel(state);
setSearchResultsError(resultsError);
setSearchResults(results);
setIsLoading(state.loading);
break;
}
}
}
useEffect(() => subscriber?.(updateState), []);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
const sha = getActiveRowInfo(activeRow ?? state.activeRow)?.id;
if (sha == null) return;
// TODO@eamodio a bit of a hack since the graph container ref isn't exposed in the types
const graph = (graphRef.current as any)?.graphContainerRef.current;
if (!e.composedPath().some(el => el === graph)) return;
const row = rows.find(r => r.sha === sha);
if (row == null) return;
onDoubleClickRow?.(row, e.key !== 'Enter');
}
};
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [activeRow]);
const minimapData = useMemo(() => {
if (!graphConfig?.minimap) return undefined;
const showLinesChanged = (graphConfig?.minimapDataType ?? 'commits') === 'lines';
if (showLinesChanged && rowsStats == null) return undefined;
// Loops through all the rows and group them by day and aggregate the row.stats
const statsByDayMap = new Map<number, GraphMinimapStats>();
const markersByDay = new Map<number, GraphMinimapMarker[]>();
const enabledMinimapMarkers: GraphMinimapMarkerTypes[] = graphConfig?.minimapMarkerTypes ?? [];
let rankedShas: {
head: string | undefined;
branch: string | undefined;
remote: string | undefined;
tag: string | undefined;
} = {
head: undefined,
branch: undefined,
remote: undefined,
tag: undefined,
};
let day;
let prevDay;
let markers;
let headMarkers: GraphMinimapMarker[];
let remoteMarkers: GraphMinimapMarker[];
let stashMarker: StashMarker | undefined;
let tagMarkers: GraphMinimapMarker[];
let row: GraphRow;
let stat;
let stats;
// Iterate in reverse order so that we can track the HEAD upstream properly
for (let i = rows.length - 1; i >= 0; i--) {
row = rows[i];
day = getDay(row.date);
if (day !== prevDay) {
prevDay = day;
rankedShas = {
head: undefined,
branch: undefined,
remote: undefined,
tag: undefined,
};
}
if (
row.heads?.length &&
(enabledMinimapMarkers.includes('head') || enabledMinimapMarkers.includes('localBranches'))
) {
rankedShas.branch = row.sha;
headMarkers = [];
// eslint-disable-next-line no-loop-func
row.heads.forEach(h => {
if (h.isCurrentHead) {
rankedShas.head = row.sha;
}
if (
enabledMinimapMarkers.includes('localBranches') ||
(enabledMinimapMarkers.includes('head') && h.isCurrentHead)
) {
headMarkers.push({
type: 'branch',
name: h.name,
current: h.isCurrentHead && enabledMinimapMarkers.includes('head'),
});
}
});
markers = markersByDay.get(day);
if (markers == null) {
markersByDay.set(day, headMarkers);
} else {
markers.push(...headMarkers);
}
}
if (
row.remotes?.length &&
(enabledMinimapMarkers.includes('upstream') ||
enabledMinimapMarkers.includes('remoteBranches') ||
enabledMinimapMarkers.includes('localBranches'))
) {
rankedShas.remote = row.sha;
remoteMarkers = [];
// eslint-disable-next-line no-loop-func
row.remotes.forEach(r => {
let current = false;
const hasDownstream = downstreams?.[`${r.owner}/${r.name}`]?.length;
if (r.current) {
rankedShas.remote = row.sha;
current = true;
}
if (
enabledMinimapMarkers.includes('remoteBranches') ||
(enabledMinimapMarkers.includes('upstream') && current) ||
(enabledMinimapMarkers.includes('localBranches') && hasDownstream)
) {
remoteMarkers.push({
type: 'remote',
name: `${r.owner}/${r.name}`,
current: current && enabledMinimapMarkers.includes('upstream'),
});
}
});
markers = markersByDay.get(day);
if (markers == null) {
markersByDay.set(day, remoteMarkers);
} else {
markers.push(...remoteMarkers);
}
}
if (row.type === 'stash-node' && enabledMinimapMarkers.includes('stashes')) {
stashMarker = { type: 'stash', name: row.message };
markers = markersByDay.get(day);
if (markers == null) {
markersByDay.set(day, [stashMarker]);
} else {
markers.push(stashMarker);
}
}
if (row.tags?.length && enabledMinimapMarkers.includes('tags')) {
rankedShas.tag = row.sha;
tagMarkers = row.tags.map<GraphMinimapMarker>(t => ({
type: 'tag',
name: t.name,
}));
markers = markersByDay.get(day);
if (markers == null) {
markersByDay.set(day, tagMarkers);
} else {
markers.push(...tagMarkers);
}
}
stat = statsByDayMap.get(day);
if (stat == null) {
if (showLinesChanged) {
stats = rowsStats![row.sha];
if (stats != null) {
stat = {
activity: { additions: stats.additions, deletions: stats.deletions },
commits: 1,
files: stats.files,
sha: row.sha,
};
statsByDayMap.set(day, stat);
}
} else {
stat = {
commits: 1,
sha: row.sha,
};
statsByDayMap.set(day, stat);
}
} else {
stat.commits++;
stat.sha = rankedShas.head ?? rankedShas.branch ?? rankedShas.remote ?? rankedShas.tag ?? stat.sha;
if (showLinesChanged) {
stats = rowsStats![row.sha];
if (stats != null) {
if (stat.activity == null) {
stat.activity = { additions: stats.additions, deletions: stats.deletions };
} else {
stat.activity.additions += stats.additions;
stat.activity.deletions += stats.deletions;
}
stat.files = (stat.files ?? 0) + stats.files;
}
}
}
}
return { stats: statsByDayMap, markers: markersByDay };
}, [
rows,
rowsStats,
downstreams,
graphConfig?.minimap,
graphConfig?.minimapDataType,
graphConfig?.minimapMarkerTypes,
]);
const minimapSearchResults = useMemo(() => {
if (!graphConfig?.minimap || !graphConfig.minimapMarkerTypes?.includes('highlights')) {
return undefined;
}
const searchResultsByDay = new Map<number, GraphMinimapSearchResultMarker>();
if (searchResults?.ids != null) {
let day;
let sha;
let r;
let result;
for ([sha, r] of Object.entries(searchResults.ids)) {
day = getDay(r.date);
result = searchResultsByDay.get(day);
if (result == null) {
searchResultsByDay.set(day, { type: 'search-result', sha: sha, count: 1 });
} else {
result.count++;
}
}
}
return searchResultsByDay;
}, [searchResults, graphConfig?.minimap, graphConfig?.minimapMarkerTypes]);
const handleOnMinimapDaySelected = (e: CustomEvent<GraphMinimapDaySelectedEventDetail>) => {
let { sha } = e.detail;
if (sha == null) {
const date = e.detail.date?.getTime();
if (date == null) return;
// Find closest row to the date
const closest = rows.reduce((prev, curr) =>
Math.abs(curr.date - date) < Math.abs(prev.date - date) ? curr : prev,
);
sha = closest.sha;
}
graphRef.current?.selectCommits([sha], false, true);
};
const handleOnMinimapToggle = (_e: React.MouseEvent) => {
onUpdateGraphConfiguration?.({ minimap: !graphConfig?.minimap });
};
// This can only be applied to one radio button for now due to a bug in the component: https://github.com/microsoft/fast/issues/6381
const handleOnMinimapDataTypeChange = (e: Event | FormEvent<HTMLElement>) => {
if (graphConfig == null) return;
const $el = e.target as HTMLInputElement;
if ($el.value === 'commits') {
const minimapDataType = $el.checked ? 'commits' : 'lines';
if (graphConfig.minimapDataType === minimapDataType) return;
setGraphConfig({ ...graphConfig, minimapDataType: minimapDataType });
onUpdateGraphConfiguration?.({ minimapDataType: minimapDataType });
}
};
const handleOnMinimapAdditionalTypesChange = (e: Event | FormEvent<HTMLElement>) => {
if (graphConfig?.minimapMarkerTypes == null) return;
const $el = e.target as HTMLInputElement;
const value = $el.value as GraphMinimapMarkerTypes;
if ($el.checked) {
if (!graphConfig.minimapMarkerTypes.includes(value)) {
const minimapMarkerTypes = [...graphConfig.minimapMarkerTypes, value];
setGraphConfig({ ...graphConfig, minimapMarkerTypes: minimapMarkerTypes });
onUpdateGraphConfiguration?.({ minimapMarkerTypes: minimapMarkerTypes });
}
} else {
const index = graphConfig.minimapMarkerTypes.indexOf(value);
if (index !== -1) {
const minimapMarkerTypes = [...graphConfig.minimapMarkerTypes];
minimapMarkerTypes.splice(index, 1);
setGraphConfig({ ...graphConfig, minimapMarkerTypes: minimapMarkerTypes });
onUpdateGraphConfiguration?.({ minimapMarkerTypes: minimapMarkerTypes });
}
}
};
const handleOnGraphMouseLeave = (_event: any) => {
minimap.current?.unselect(undefined, true);
};
const handleOnGraphRowHovered = (_event: any, graphZoneType: GraphZoneType, graphRow: GraphRow) => {
if (graphZoneType === refZone || minimap.current == null) return;
minimap.current?.select(graphRow.date, true);
};
useEffect(() => {
if (searchResultsError != null || searchResults == null || searchResults.count === 0 || searchQuery == null) {
return;
}
searchEl.current?.logSearch(searchQuery);
}, [searchResults]);
const searchPosition: number = useMemo(() => {
if (searchResults?.ids == null || !searchQuery?.query) return 0;
const id = getActiveRowInfo(activeRow)?.id;
let searchIndex = id ? searchResults.ids[id]?.i : undefined;
if (searchIndex == null) {
[searchIndex] = getClosestSearchResultIndex(searchResults, searchQuery, activeRow);
}
return searchIndex < 1 ? 1 : searchIndex + 1;
}, [activeRow, searchResults]);
const isAllBranches = useMemo(() => {
if (includeOnlyRefsById == null) {
return true;
}
return Object.keys(includeOnlyRefsById).length === 0;
}, [includeOnlyRefsById]);
const hasFilters = useMemo(() => {
if (!isAllBranches) return true;
if (excludeTypes == null) return false;
return Object.values(excludeTypes).includes(true);
}, [excludeTypes, isAllBranches, graphConfig?.dimMergeCommits]);
const hasSpecialFilters = useMemo(() => {
return !isAllBranches;
}, [isAllBranches]);
const handleSearchInput = (e: CustomEvent<SearchQuery>) => {
const detail = e.detail;
setSearchQuery(detail);
const isValid = detail.query.length >= 3;
setSearchResults(undefined);
setSearchResultsError(undefined);
setSearchResultsHidden(false);
setSearching(isValid);
onSearch?.(isValid ? detail : undefined);
};
const handleSearchOpenInView = () => {
if (searchQuery == null) return;
onSearchOpenInView?.(searchQuery);
};
const ensureSearchResultRow = async (id: string): Promise<string | undefined> => {
if (onEnsureRowPromise == null) return id;
if (ensuredIds.current.has(id)) return id;
if (ensuredSkippedIds.current.has(id)) return undefined;
let timeout: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {
timeout = undefined;
setIsLoading(true);
}, 500);
const e = await onEnsureRowPromise(id, false);
if (timeout == null) {
setIsLoading(false);
} else {
clearTimeout(timeout);
}
if (e?.id === id) {
ensuredIds.current.add(id);
return id;
}
if (e != null) {
ensuredSkippedIds.current.add(id);
}
return undefined;
};
const handleSearchNavigation = async (e: CustomEvent<SearchNavigationEventDetail>) => {
if (searchResults == null) return;
const direction = e.detail?.direction ?? 'next';
let results = searchResults;
let count = results.count;
let searchIndex;
let id: string | undefined;
let next;
if (direction === 'first') {
next = false;
searchIndex = 0;
} else if (direction === 'last') {
next = false;
searchIndex = -1;
} else {
next = direction === 'next';
[searchIndex, id] = getClosestSearchResultIndex(results, searchQuery, activeRow, next);
}
let iterations = 0;
// Avoid infinite loops
while (iterations < 1000) {
iterations++;
// Indicates a boundary and we need to load more results
if (searchIndex == -1) {
if (next) {
if (searchQuery != null && results?.paging?.hasMore) {
setSearching(true);
let moreResults;
try {
moreResults = await onSearchPromise?.(searchQuery, { more: true });
} finally {
setSearching(false);
}
if (moreResults?.results != null && !('error' in moreResults.results)) {
if (count < moreResults.results.count) {
results = moreResults.results;
searchIndex = count;
count = results.count;
} else {
searchIndex = 0;
}
} else {
searchIndex = 0;
}
} else {
searchIndex = 0;
}
} else if (direction === 'last' && searchQuery != null && results?.paging?.hasMore) {
setSearching(true);
let moreResults;
try {
moreResults = await onSearchPromise?.(searchQuery, { limit: 0, more: true });
} finally {
setSearching(false);
}
if (moreResults?.results != null && !('error' in moreResults.results)) {
if (count < moreResults.results.count) {
results = moreResults.results;
count = results.count;
}
searchIndex = count;
}
} else {
searchIndex = count - 1;
}
}
id = id ?? getSearchResultIdByIndex(results, searchIndex);
if (id != null) {
id = await ensureSearchResultRow(id);
if (id != null) break;
}
setSearchResultsHidden(true);
searchIndex = getNextOrPreviousSearchResultIndex(searchIndex, next, results, searchQuery);
}
if (id != null) {
queueMicrotask(() => graphRef.current?.selectCommits([id!], false, true));
}
};
const handleChooseRepository = () => {
onChooseRepository?.();
};
const handleExcludeTypeChange = (e: Event | FormEvent<HTMLElement>) => {
const $el = e.target as HTMLInputElement;
const value = $el.value;
const isLocalBranches = ['branch-all', 'branch-current'].includes(value);
if (!isLocalBranches && !['remotes', 'stashes', 'tags', 'mergeCommits'].includes(value)) return;
const isChecked = $el.checked;
if (value === 'mergeCommits') {
onDimMergeCommits?.(isChecked);
return;
}
const key = value as keyof GraphExcludeTypes;
const currentFilter = excludeTypes?.[key];
if ((currentFilter == null && isChecked) || (currentFilter != null && currentFilter !== isChecked)) {
setExcludeTypes({
...excludeTypes,
[key]: isChecked,
});
onExcludeType?.(key, isChecked);
}
};
// This can only be applied to one radio button for now due to a bug in the component: https://github.com/microsoft/fast/issues/6381
const handleLocalBranchFiltering = (e: Event | FormEvent<HTMLElement>) => {
const $el = e.target as HTMLInputElement;
const value = $el.value;
const isChecked = $el.checked;
const wantsAllBranches = value === 'branch-all' && isChecked;
if (isAllBranches === wantsAllBranches) {
return;
}
onIncludeOnlyRef?.(wantsAllBranches);
};
const handleMissingAvatars = (emails: GraphAvatars) => {
onMissingAvatars?.(emails);
};
const handleMissingRefsMetadata = (metadata: GraphMissingRefsMetadata) => {
onMissingRefsMetadata?.(metadata);
};
const handleToggleColumnSettings = (event: React.MouseEvent<HTMLButtonElement>) => {
const e = event.nativeEvent;
const evt = new MouseEvent('contextmenu', {
bubbles: true,
clientX: e.clientX,
clientY: e.clientY,
});
e.target?.dispatchEvent(evt);
e.stopImmediatePropagation();
};
const handleMoreCommits = () => {
setIsLoading(true);
onMoreRows?.();
};
const handleOnColumnResized = (columnName: GraphColumnName, columnSettings: GraphColumnSetting) => {
if (columnSettings.width) {
onColumnsChange?.({
[columnName]: {
width: columnSettings.width,
isHidden: columnSettings.isHidden,
mode: columnSettings.mode as GraphColumnMode,
order: columnSettings.order,
},
});
}
};
const handleOnGraphVisibleRowsChanged = (top: GraphRow, bottom: GraphRow) => {
setVisibleDays({
top: new Date(top.date).setHours(23, 59, 59, 999),
bottom: new Date(bottom.date).setHours(0, 0, 0, 0),
});
};
const handleOnGraphColumnsReOrdered = (columnsSettings: GraphColumnsSettings) => {
const graphColumnsConfig: GraphColumnsConfig = {};
for (const [columnName, config] of Object.entries(columnsSettings as GraphColumnsConfig)) {
graphColumnsConfig[columnName] = { ...config };
}
onColumnsChange?.(graphColumnsConfig);
};
const handleOnToggleRefsVisibilityClick = (_event: any, refs: GraphRefOptData[], visible: boolean) => {
onRefsVisibilityChange?.(refs, visible);
};
const handleOnDoubleClickRef = (
_event: React.MouseEvent<HTMLButtonElement>,
refGroup: GraphRefGroup,
_row: GraphRow,
metadata?: GraphRefMetadataItem,
) => {
if (refGroup.length > 0) {
onDoubleClickRef?.(refGroup[0], metadata);
}
};
const handleOnDoubleClickRow = (
_event: React.MouseEvent<HTMLButtonElement>,
graphZoneType: GraphZoneType,
row: GraphRow,
) => {
if (graphZoneType === refZone) return;
onDoubleClickRow?.(row, true);
};
const handleSelectGraphRows = (rows: GraphRow[]) => {
const active = rows[0];
const activeKey = active != null ? `${active.sha}|${active.date}` : undefined;
// HACK: Ensure the main state is updated since it doesn't come from the extension
state.activeRow = activeKey;
setActiveRow(activeKey);
setActiveDay(active?.date);
onSelectionChange?.(rows);
};
const renderFetchAction = () => {
const lastFetchedDate = lastFetched && new Date(lastFetched);
const fetchedText = lastFetchedDate && lastFetchedDate.getTime() !== 0 ? fromNow(lastFetchedDate) : undefined;
let action: 'fetch' | 'pull' | 'push' = 'fetch';
let icon = 'sync';
let label = 'Fetch';
let isBehind = false;
let isAhead = false;
let tooltip = '';
let fetchTooltip = 'Fetch from';
let remote = 'remote';
if (branchState) {
isBehind = branchState.behind > 0;
isAhead = branchState.ahead > 0;
const branchPrefix = `Branch ${branchName} is`;
remote = `${branchState.upstream}${branchState.provider?.name ? ` on ${branchState.provider?.name}` : ''}`;
if (isBehind) {
action = 'pull';
icon = 'arrow-down';
label = 'Pull';
tooltip = `Pull from ${remote}\n${branchPrefix} ${pluralize('commit', branchState.behind)} behind of`;
} else if (isAhead) {
action = 'push';
icon = 'arrow-up';
label = 'Push';
tooltip = `Push to ${remote}\n${branchPrefix} ${pluralize('commit', branchState.ahead)} ahead of`;
}
tooltip += ` ${remote}`;
fetchTooltip += ` ${remote}`;
}
if (fetchedText != null) {
const lastFetchedText = `\nLast fetched ${fetchedText}`;
tooltip += lastFetchedText;
fetchTooltip += lastFetchedText;
}
return (
<div className="titlebar__group">
{(isBehind || isAhead) && (
<a
href={createWebviewCommandLink(
`gitlens.graph.${action}`,
state.webviewId,
state.webviewInstanceId,
)}
className={`action-button${isBehind ? ' is-behind' : ''}${isAhead ? ' is-ahead' : ''}`}
title={tooltip}
>
<span className={`codicon codicon-${icon} action-button__icon`}></span>
{label}
{(isAhead || isBehind) && (
<span>
<span className="pill action-button__pill">
{isAhead && (
<span>
{branchState!.ahead} <span className="codicon codicon-arrow-up"></span>
</span>
)}
{isBehind && (
<span>
{branchState!.behind} <span className="codicon codicon-arrow-down"></span>
</span>
)}
</span>
</span>
)}
</a>
)}
<a
href={createWebviewCommandLink('gitlens.graph.fetch', state.webviewId, state.webviewInstanceId)}
className="action-button"
title={fetchTooltip}
>
<span className="codicon codicon-sync action-button__icon"></span>
Fetch
{fetchedText && <span className="action-button__small">({fetchedText})</span>}
</a>
</div>
);
};
return (
<>
<header className="titlebar graph-app__header">
<div
className={`titlebar__row titlebar__row--wrap${
!allowed ? ' disallowed' : repo && branchState?.provider?.url ? '' : ' no-remote-provider'
}`}
>
{repo && branchState?.provider?.url && (
<a
href={branchState.provider.url}
className="action-button"
style={{ marginRight: '-0.5rem' }}
title={`Open Repository on ${branchState.provider.name}`}
aria-label={`Open Repository on ${branchState.provider.name}`}
>
<span
className={
branchState.provider.icon === 'cloud'
? 'codicon codicon-cloud action-button__icon'
: `glicon glicon-provider-${branchState.provider.icon} action-button__icon`
}
aria-hidden="true"
></span>
</a>
)}
<button
type="button"
className="action-button"
slot="trigger"
title="Switch to Another Repository..."
aria-label="Switch to Another Repository..."
disabled={repos.length < 2}
onClick={() => handleChooseRepository()}
>
{repo?.formattedName ?? 'none selected'}
{repos.length > 1 && (
<span
className="codicon codicon-chevron-down action-button__more"
aria-hidden="true"
></span>
)}
</button>
{allowed && repo && (
<>
<span>
<span className="codicon codicon-chevron-right"></span>
</span>
<a
href={createWebviewCommandLink(
'gitlens.graph.switchToAnotherBranch',
state.webviewId,
state.webviewInstanceId,
)}
className="action-button"
title="Switch to Another Branch..."
aria-label="Switch to Another Branch..."
>
{branchName}
<span
className="codicon codicon-chevron-down action-button__more"
aria-hidden="true"
></span>
</a>
<span>
<span className="codicon codicon-chevron-right"></span>
</span>
{renderFetchAction()}
</>
)}
<FeatureGateBadge subscription={subscription}></FeatureGateBadge>
<div className="popover">
<a href="command:gitlens.showFocusPage" className="action-button popover__trigger">
Try the Focus Preview
</a>
<PopOver placement="top end" className="popover__content">
Bring all of your GitHub pull requests and issues into a unified actionable to help to you
more easily juggle work in progress, pending work, reviews, and more
</PopOver>
</div>
</div>
{allowed && (
<div className="titlebar__row">
<div className="titlebar__group">
<PopMenu>
<button type="button" className="action-button" slot="trigger" title="Filter Graph">
<span className={`codicon codicon-filter${hasFilters ? '-filled' : ''}`}></span>
{hasSpecialFilters && <span className="action-button__indicator"></span>}
<span
className="codicon codicon-chevron-down action-button__more"
aria-hidden="true"
></span>
</button>
<MenuList slot="content">
<MenuLabel>Filter options</MenuLabel>
<MenuItem role="none">
<VSCodeRadioGroup
orientation="vertical"
value={
isAllBranches && repo?.isVirtual !== true
? 'branch-all'
: 'branch-current'
}
readOnly={repo?.isVirtual === true}
>
{repo?.isVirtual !== true && (
<VSCodeRadio
name="branching-toggle"
value="branch-all"
onChange={handleLocalBranchFiltering}
>
Show All Branches
</VSCodeRadio>
)}
<VSCodeRadio name="branching-toggle" value="branch-current">
Show Current Branch Only
</VSCodeRadio>
</VSCodeRadioGroup>
</MenuItem>
<MenuDivider></MenuDivider>
{repo?.isVirtual !== true && (
<>
<MenuItem role="none">
<VSCodeCheckbox
value="remotes"
onChange={handleExcludeTypeChange}
defaultChecked={excludeTypes?.remotes ?? false}
>
Hide Remote-only Branches
</VSCodeCheckbox>
</MenuItem>
<MenuItem role="none">
<VSCodeCheckbox
value="stashes"
onChange={handleExcludeTypeChange}
defaultChecked={excludeTypes?.stashes ?? false}
>
Hide Stashes
</VSCodeCheckbox>
</MenuItem>
</>
)}
<MenuItem role="none">
<VSCodeCheckbox
value="tags"
onChange={handleExcludeTypeChange}
defaultChecked={excludeTypes?.tags ?? false}
>
Hide Tags
</VSCodeCheckbox>
</MenuItem>
<MenuDivider></MenuDivider>
<MenuItem role="none">
<VSCodeCheckbox
value="mergeCommits"
onChange={handleExcludeTypeChange}
defaultChecked={graphConfig?.dimMergeCommits ?? false}
>
Dim Merge Commit Rows
</VSCodeCheckbox>
</MenuItem>
</MenuList>
</PopMenu>
<span>
<span className="action-divider"></span>
</span>
<SearchBox
ref={searchEl}
label="Search Commits"
step={searchPosition}
total={searchResults?.count ?? 0}
valid={Boolean(searchQuery?.query && searchQuery.query.length > 2)}
more={searchResults?.paging?.hasMore ?? false}
searching={searching}
value={searchQuery?.query ?? ''}
errorMessage={searchResultsError?.error ?? ''}
resultsHidden={searchResultsHidden}
resultsLoaded={searchResults != null}
onChange={e => handleSearchInput(e)}
onNavigate={e => handleSearchNavigation(e)}
onOpenInView={() => handleSearchOpenInView()}
/>
<span>
<span className="action-divider"></span>
</span>
<span className="button-group">
<button
type="button"
role="checkbox"
className="action-button"
title="Toggle Minimap"
aria-label="Toggle Minimap"
aria-checked={graphConfig?.minimap ?? false}
onClick={handleOnMinimapToggle}
>
<span className="codicon codicon-graph-line action-button__icon"></span>
</button>
<PopMenu position="right">
<button
type="button"
className="action-button"
slot="trigger"
title="Minimap Options"
>
<span
className="codicon codicon-chevron-down action-button__more"
aria-hidden="true"
></span>
</button>
<MenuList slot="content">
<MenuLabel>Chart</MenuLabel>
<MenuItem role="none">
<VSCodeRadioGroup
orientation="vertical"
value={graphConfig?.minimapDataType ?? 'commits'}
>
<VSCodeRadio
name="minimap-datatype"
value="commits"
onChange={handleOnMinimapDataTypeChange}
>
Commits
</VSCodeRadio>
<VSCodeRadio name="minimap-datatype" value="lines">
Lines Changed
</VSCodeRadio>
</VSCodeRadioGroup>
</MenuItem>
<MenuDivider></MenuDivider>
<MenuLabel>Markers</MenuLabel>
<MenuItem role="none">
<VSCodeCheckbox
value="localBranches"
onChange={handleOnMinimapAdditionalTypesChange}
defaultChecked={
graphConfig?.minimapMarkerTypes?.includes('localBranches') ?? false
}
>
<span
className="minimap-marker-swatch"
data-marker="localBranches"
></span>
Local Branches
</VSCodeCheckbox>
</MenuItem>
<MenuItem role="none">
<VSCodeCheckbox
value="remoteBranches"
onChange={handleOnMinimapAdditionalTypesChange}
defaultChecked={
graphConfig?.minimapMarkerTypes?.includes('remoteBranches') ?? true
}
>
<span
className="minimap-marker-swatch"
data-marker="remoteBranches"
></span>
Remote Branches
</VSCodeCheckbox>
</MenuItem>
<MenuItem role="none">
<VSCodeCheckbox
value="stashes"
onChange={handleOnMinimapAdditionalTypesChange}
defaultChecked={
graphConfig?.minimapMarkerTypes?.includes('stashes') ?? false
}
>
<span className="minimap-marker-swatch" data-marker="stashes"></span>
Stashes
</VSCodeCheckbox>
</MenuItem>
<MenuItem role="none">
<VSCodeCheckbox
value="tags"
onChange={handleOnMinimapAdditionalTypesChange}
defaultChecked={
graphConfig?.minimapMarkerTypes?.includes('tags') ?? true
}
>
<span className="minimap-marker-swatch" data-marker="tags"></span>
Tags
</VSCodeCheckbox>
</MenuItem>
</MenuList>
</PopMenu>
</span>
</div>
</div>
)}
<div
className={`progress-container infinite${isLoading || rowsStatsLoading ? ' active' : ''}`}
role="progressbar"
>
<div className="progress-bar"></div>
</div>
</header>
<FeatureGate className="graph-app__gate" appearance="alert" state={subscription?.state} visible={!allowed}>
<p slot="feature">
Helps you easily visualize your repository and keep track of all work in progress.
<br />
<br />
Use the rich commit search to find exactly what you're looking for. It's powerful filters allow you
to search by a specific commit, message, author, a changed file or files, or even a specific code
change.
</p>
</FeatureGate>
{graphConfig?.minimap && (
<GraphMinimap
ref={minimap as any}
activeDay={activeDay}
data={minimapData?.stats}
dataType={graphConfig?.minimapDataType ?? 'commits'}
markers={minimapData?.markers}
searchResults={minimapSearchResults}
visibleDays={visibleDays}
onSelected={e => handleOnMinimapDaySelected(e)}
></GraphMinimap>
)}
<main id="main" className="graph-app__main" aria-hidden={!allowed}>
{repo !== undefined ? (
<>
<GraphContainer
ref={graphRef}
avatarUrlByEmail={avatars}
columnsSettings={columns}
contexts={context}
cssVariables={styleProps?.cssVariables}
dimMergeCommits={graphConfig?.dimMergeCommits}
downstreamsByUpstream={downstreams}
enabledRefMetadataTypes={graphConfig?.enabledRefMetadataTypes}
enabledScrollMarkerTypes={graphConfig?.scrollMarkerTypes}
enableShowHideRefsOptions
enableMultiSelection={graphConfig?.enableMultiSelection}
excludeRefsById={excludeRefsById}
excludeByType={excludeTypes}
formatCommitDateTime={getGraphDateFormatter(graphConfig)}
getExternalIcon={getIconElementLibrary}
graphRows={rows}
hasMoreCommits={pagingHasMore}
// Just cast the { [id: string]: number } object to { [id: string]: boolean } for performance
highlightedShas={searchResults?.ids as GraphContainerProps['highlightedShas']}
highlightRowsOnRefHover={graphConfig?.highlightRowsOnRefHover}
includeOnlyRefsById={includeOnlyRefsById}
scrollRowPadding={graphConfig?.scrollRowPadding}
showGhostRefsOnRowHover={graphConfig?.showGhostRefsOnRowHover}
showRemoteNamesOnRefs={graphConfig?.showRemoteNamesOnRefs}
isContainerWindowFocused={windowFocused}
isLoadingRows={isLoading}
isSelectedBySha={selectedRows}
nonce={nonce}
onColumnResized={handleOnColumnResized}
onDoubleClickGraphRow={handleOnDoubleClickRow}
onDoubleClickGraphRef={handleOnDoubleClickRef}
onGraphColumnsReOrdered={handleOnGraphColumnsReOrdered}
onGraphMouseLeave={minimap.current ? handleOnGraphMouseLeave : undefined}
onGraphRowHovered={minimap.current ? handleOnGraphRowHovered : undefined}
onSettingsClick={handleToggleColumnSettings}
onSelectGraphRows={handleSelectGraphRows}
onToggleRefsVisibilityClick={handleOnToggleRefsVisibilityClick}
onEmailsMissingAvatarUrls={handleMissingAvatars}
onRefsMissingMetadata={handleMissingRefsMetadata}
onShowMoreCommits={handleMoreCommits}
onGraphVisibleRowsChanged={minimap.current ? handleOnGraphVisibleRowsChanged : undefined}
platform={clientPlatform}
refMetadataById={refsMetadata}
rowsStats={rowsStats}
rowsStatsLoading={rowsStatsLoading}
shaLength={graphConfig?.idLength}
themeOpacityFactor={styleProps?.themeOpacityFactor}
useAuthorInitialsForAvatars={!graphConfig?.avatars}
workDirStats={workingTreeStats}
/>
</>
) : (
<p>No repository is selected</p>
)}
</main>
</>
);
}
function formatCommitDateTime(
date: number,
style: DateStyle = 'absolute',
format: DateTimeFormat | string = 'short+short',
source?: CommitDateTimeSources,
): string {
switch (source) {
case CommitDateTimeSources.Tooltip:
return `${formatDate(date, format)} (${fromNow(date)})`;
case CommitDateTimeSources.RowEntry:
default:
return style === 'relative' ? fromNow(date) : formatDate(date, format);
}
}
function getClosestSearchResultIndex(
results: GraphSearchResults,
query: SearchQuery | undefined,
activeRow: string | undefined,
next: boolean = true,
): [number, string | undefined] {
if (results.ids == null) return [0, undefined];
const activeInfo = getActiveRowInfo(activeRow);
const activeId = activeInfo?.id;
if (activeId == null) return [0, undefined];
let index: number | undefined;
let nearestId: string | undefined;
let nearestIndex: number | undefined;
const data = results.ids[activeId];
if (data != null) {
index = data.i;
nearestId = activeId;
nearestIndex = index;
}
if (index == null) {
const activeDate = activeInfo?.date != null ? activeInfo.date + (next ? 1 : -1) : undefined;
if (activeDate == null) return [0, undefined];
// Loop through the search results and:
// try to find the active id
// if next=true find the nearest date before the active date
// if next=false find the nearest date after the active date
let i: number;
let id: string;
let date: number;
let nearestDate: number | undefined;
for ([id, { date, i }] of Object.entries(results.ids)) {
if (next) {
if (date < activeDate && (nearestDate == null || date > nearestDate)) {
nearestId = id;
nearestDate = date;
nearestIndex = i;
}
} else if (date > activeDate && (nearestDate == null || date <= nearestDate)) {
nearestId = id;
nearestDate = date;
nearestIndex = i;
}
}
index = nearestIndex == null ? results.count - 1 : nearestIndex + (next ? -1 : 1);
}
index = getNextOrPreviousSearchResultIndex(index, next, results, query);
return index === nearestIndex ? [index, nearestId] : [index, undefined];
}
function getNextOrPreviousSearchResultIndex(
index: number,
next: boolean,
results: GraphSearchResults,
query: SearchQuery | undefined,
) {
if (next) {
if (index < results.count - 1) {
index++;
} else if (query != null && results?.paging?.hasMore) {
index = -1; // Indicates a boundary that we should load more results
} else {
index = 0;
}
} else if (index > 0) {
index--;
} else if (query != null && results?.paging?.hasMore) {
index = -1; // Indicates a boundary that we should load more results
} else {
index = results.count - 1;
}
return index;
}
function getSearchResultIdByIndex(results: GraphSearchResults, index: number): string | undefined {
// Loop through the search results without using Object.entries or Object.keys and return the id at the specified index
const { ids } = results;
for (const id in ids) {
if (ids[id].i === index) return id;
}
return undefined;
// return Object.entries(results.ids).find(([, { i }]) => i === index)?.[0];
}
function getActiveRowInfo(activeRow: string | undefined): { id: string; date: number } | undefined {
if (activeRow == null) return undefined;
const [id, date] = activeRow.split('|');
return {
id: id,
date: Number(date),
};
}
function getSearchResultModel(state: State): {
results: GraphSearchResults | undefined;
resultsError: GraphSearchResultsError | undefined;
} {
let results: GraphSearchResults | undefined;
let resultsError: GraphSearchResultsError | undefined;
if (state.searchResults != null) {
if ('error' in state.searchResults) {
resultsError = state.searchResults;
} else {
results = state.searchResults;
}
}
return { results: results, resultsError: resultsError };
}
function getDay(date: number | Date): number {
return new Date(date).setHours(0, 0, 0, 0);
}