Co-authored-by: ericf-axosoft <90025366+ericf-axosoft@users.noreply.github.com> Co-authored-by: Ramin Tadayon <ramin.tadayon@gitkraken.com> Co-authored-by: Miggy Eusebio <miggy.eusebio@gitkraken.com> Co-authored-by: Keith Daulton <kdaulton@d13design.com>main
@ -0,0 +1,393 @@ | |||
import type { CommitType } from '@gitkraken/gitkraken-components'; | |||
import { commitNodeType, mergeNodeType, stashNodeType } from '@gitkraken/gitkraken-components'; | |||
import type { Disposable } from 'vscode'; | |||
import { ViewColumn, window } from 'vscode'; | |||
import { configuration } from '../../../configuration'; | |||
import { Commands } from '../../../constants'; | |||
import type { Container } from '../../../container'; | |||
import { emojify } from '../../../emojis'; | |||
import type { GitBranch } from '../../../git/models/branch'; | |||
import type { GitCommit, GitStashCommit } from '../../../git/models/commit'; | |||
import { isStash } from '../../../git/models/commit'; | |||
import type { GitLog } from '../../../git/models/log'; | |||
import type { GitRemote } from '../../../git/models/remote'; | |||
import type { Repository, RepositoryChangeEvent } from '../../../git/models/repository'; | |||
import type { GitTag } from '../../../git/models/tag'; | |||
import { RepositoryPicker } from '../../../quickpicks/repositoryPicker'; | |||
import { WorkspaceStorageKeys } from '../../../storage'; | |||
import type { IpcMessage } from '../../../webviews/protocol'; | |||
import { onIpc } from '../../../webviews/protocol'; | |||
import { WebviewWithConfigBase } from '../../../webviews/webviewWithConfigBase'; | |||
import { ensurePlusFeaturesEnabled } from '../../subscription/utils'; | |||
import type { | |||
GraphColumnConfig, | |||
GraphColumnConfigDictionary, | |||
GraphCommit, | |||
GraphConfig as GraphConfigWithColumns, | |||
GraphRepository, | |||
State, | |||
} from './protocol'; | |||
import { | |||
ColumnChangeCommandType, | |||
DidChangeCommitsNotificationType, | |||
DidChangeConfigNotificationType, | |||
DidChangeNotificationType, | |||
MoreCommitsCommandType, | |||
SelectRepositoryCommandType, | |||
} from './protocol'; | |||
export class GraphWebview extends WebviewWithConfigBase<State> { | |||
private selectedRepository?: Repository; | |||
private currentLog?: GitLog; | |||
private repoDisposable: Disposable | undefined; | |||
private defaultTitle?: string; | |||
constructor(container: Container) { | |||
super(container, 'gitlens.graph', 'graph.html', 'images/gitlens-icon.png', 'Graph', Commands.ShowGraphPage); | |||
this.defaultTitle = this.title; | |||
this.disposables.push({ dispose: () => void this.repoDisposable?.dispose() }); | |||
} | |||
override async show(column: ViewColumn = ViewColumn.Active): Promise<void> { | |||
if (!(await ensurePlusFeaturesEnabled())) return; | |||
return super.show(column); | |||
} | |||
protected override onMessageReceived(e: IpcMessage) { | |||
switch (e.method) { | |||
case ColumnChangeCommandType.method: | |||
onIpc(ColumnChangeCommandType, e, params => this.changeColumn(params.name, params.config)); | |||
break; | |||
case MoreCommitsCommandType.method: | |||
onIpc(MoreCommitsCommandType, e, params => this.moreCommits(params.limit)); | |||
break; | |||
case SelectRepositoryCommandType.method: | |||
onIpc(SelectRepositoryCommandType, e, params => this.changeRepository(params.path)); | |||
break; | |||
} | |||
} | |||
private changeColumn(name: string, config: GraphColumnConfig) { | |||
const columns = | |||
this.container.storage.getWorkspace<GraphColumnConfigDictionary>(WorkspaceStorageKeys.GraphColumns) ?? {}; | |||
columns[name] = config; | |||
void this.container.storage.storeWorkspace<GraphColumnConfigDictionary>( | |||
WorkspaceStorageKeys.GraphColumns, | |||
columns, | |||
); | |||
void this.notifyDidChangeConfig(); | |||
} | |||
private async moreCommits(limit?: number) { | |||
if (this.currentLog?.more !== undefined) { | |||
const { defaultLimit, pageLimit } = this.getConfig(); | |||
const nextLog = await this.currentLog.more(limit ?? pageLimit ?? defaultLimit); | |||
if (nextLog !== undefined) { | |||
this.currentLog = nextLog; | |||
} | |||
} | |||
void this.notifyDidChangeCommits(); | |||
} | |||
private changeRepository(path: string) { | |||
if (this.selectedRepository?.path !== path) { | |||
this.selectedRepository = path ? this.getRepos().find(r => r.path === path) : undefined; | |||
this.currentLog = undefined; | |||
} | |||
void this.notifyDidChangeState(); | |||
} | |||
private async notifyDidChangeConfig() { | |||
return this.notify(DidChangeConfigNotificationType, { | |||
config: this.getConfig(), | |||
}); | |||
} | |||
private async notifyDidChangeCommits() { | |||
const [commitsAndLog, stashCommits] = await Promise.all([this.getCommits(), this.getStashCommits()]); | |||
const log = commitsAndLog?.log; | |||
const combinedCommitsWithFilteredStashes = combineAndFilterStashCommits( | |||
commitsAndLog?.commits, | |||
stashCommits, | |||
log, | |||
); | |||
return this.notify(DidChangeCommitsNotificationType, { | |||
commits: formatCommits(combinedCommitsWithFilteredStashes), | |||
log: log != null ? formatLog(log) : undefined, | |||
}); | |||
} | |||
private async notifyDidChangeState() { | |||
return this.notify(DidChangeNotificationType, { | |||
state: await this.getState(), | |||
}); | |||
// return window.withProgress({ location: { viewId: this.id } }, async () => { | |||
// void this.notify(DidChangeNotificationType, { | |||
// state: await this.getState(), | |||
// }); | |||
// }); | |||
} | |||
private getRepos(): Repository[] { | |||
return this.container.git.openRepositories; | |||
} | |||
private async getLog(repo: string | Repository): Promise<GitLog | undefined> { | |||
const repository = typeof repo === 'string' ? this.container.git.getRepository(repo) : repo; | |||
if (repository === undefined) { | |||
return undefined; | |||
} | |||
const { defaultLimit, pageLimit } = this.getConfig(); | |||
return this.container.git.getLog(repository.uri, { | |||
all: true, | |||
limit: pageLimit ?? defaultLimit, | |||
}); | |||
} | |||
private async getCommits(): Promise<{ log: GitLog; commits: GitCommit[] } | undefined> { | |||
if (this.selectedRepository === undefined) { | |||
return undefined; | |||
} | |||
if (this.currentLog === undefined) { | |||
const log = await this.getLog(this.selectedRepository); | |||
if (log?.commits === undefined) { | |||
return undefined; | |||
} | |||
this.currentLog = log; | |||
} | |||
if (this.currentLog?.commits === undefined) { | |||
return undefined; | |||
} | |||
return { | |||
log: this.currentLog, | |||
commits: Array.from(this.currentLog.commits.values()), | |||
}; | |||
} | |||
private async getRemotes(): Promise<GitRemote[] | undefined> { | |||
if (this.selectedRepository === undefined) { | |||
return undefined; | |||
} | |||
return this.selectedRepository.getRemotes(); | |||
} | |||
private async getTags(): Promise<GitTag[] | undefined> { | |||
if (this.selectedRepository === undefined) { | |||
return undefined; | |||
} | |||
const tags = await this.container.git.getTags(this.selectedRepository.uri); | |||
if (tags === undefined) { | |||
return undefined; | |||
} | |||
return Array.from(tags.values); | |||
} | |||
private async getBranches(): Promise<GitBranch[] | undefined> { | |||
if (this.selectedRepository === undefined) { | |||
return undefined; | |||
} | |||
const branches = await this.container.git.getBranches(this.selectedRepository.uri); | |||
if (branches === undefined) { | |||
return undefined; | |||
} | |||
return Array.from(branches.values); | |||
} | |||
private async getStashCommits(): Promise<GitStashCommit[] | undefined> { | |||
if (this.selectedRepository === undefined) { | |||
return undefined; | |||
} | |||
const stash = await this.container.git.getStash(this.selectedRepository.uri); | |||
if (stash === undefined || stash.commits === undefined) { | |||
return undefined; | |||
} | |||
return Array.from(stash?.commits?.values()); | |||
} | |||
private async pickRepository(repositories: Repository[]): Promise<Repository | undefined> { | |||
if (repositories.length === 0) { | |||
return undefined; | |||
} | |||
if (repositories.length === 1) { | |||
return repositories[0]; | |||
} | |||
const repoPath = ( | |||
await RepositoryPicker.getBestRepositoryOrShow( | |||
undefined, | |||
window.activeTextEditor, | |||
'Choose a repository to visualize', | |||
) | |||
)?.path; | |||
return repositories.find(r => r.path === repoPath); | |||
} | |||
private getConfig(): GraphConfigWithColumns { | |||
const settings = configuration.get('graph'); | |||
const config: GraphConfigWithColumns = { | |||
...settings, | |||
columns: this.container.storage.getWorkspace<GraphColumnConfigDictionary>( | |||
WorkspaceStorageKeys.GraphColumns, | |||
), | |||
}; | |||
return config; | |||
} | |||
private onRepositoryChanged(_e: RepositoryChangeEvent) { | |||
// TODO: e.changed(RepositoryChange.Heads) | |||
this.currentLog = undefined; | |||
void this.notifyDidChangeState(); | |||
} | |||
private async getState(): Promise<State> { | |||
const repositories = this.getRepos(); | |||
if (repositories.length === 0) { | |||
return { | |||
repositories: [], | |||
}; | |||
} | |||
if (this.selectedRepository === undefined) { | |||
const idealRepo = await this.pickRepository(repositories); | |||
this.selectedRepository = idealRepo; | |||
this.repoDisposable?.dispose(); | |||
if (this.selectedRepository != null) { | |||
this.repoDisposable = this.selectedRepository.onDidChange(this.onRepositoryChanged, this); | |||
} | |||
} | |||
if (this.selectedRepository !== undefined) { | |||
this.title = `${this.defaultTitle}: ${this.selectedRepository.formattedName}`; | |||
} | |||
const [commitsAndLog, remotes, tags, branches, stashCommits] = await Promise.all([ | |||
this.getCommits(), | |||
this.getRemotes(), | |||
this.getTags(), | |||
this.getBranches(), | |||
this.getStashCommits(), | |||
]); | |||
const log = commitsAndLog?.log; | |||
const combinedCommitsWithFilteredStashes = combineAndFilterStashCommits( | |||
commitsAndLog?.commits, | |||
stashCommits, | |||
log, | |||
); | |||
return { | |||
repositories: formatRepositories(repositories), | |||
selectedRepository: this.selectedRepository?.path, | |||
commits: formatCommits(combinedCommitsWithFilteredStashes), | |||
remotes: remotes, // TODO: add a format function | |||
branches: branches, // TODO: add a format function | |||
tags: tags, // TODO: add a format function | |||
config: this.getConfig(), | |||
log: log != null ? formatLog(log) : undefined, | |||
nonce: this.cspNonce, | |||
}; | |||
} | |||
protected override async includeBootstrap(): Promise<State> { | |||
return this.getState(); | |||
} | |||
} | |||
function formatCommits(commits: (GitCommit | GitStashCommit)[]): GraphCommit[] { | |||
return commits.map((commit: GitCommit) => ({ | |||
sha: commit.sha, | |||
author: commit.author, | |||
message: emojify(commit.message && String(commit.message).length ? commit.message : commit.summary), | |||
parents: commit.parents, | |||
committer: commit.committer, | |||
type: getCommitType(commit), | |||
})); | |||
} | |||
function getCommitType(commit: GitCommit | GitStashCommit): CommitType { | |||
if (isStash(commit)) { | |||
return stashNodeType as CommitType; | |||
} | |||
if (commit.parents.length > 1) { | |||
return mergeNodeType as CommitType; | |||
} | |||
// TODO: add other needed commit types for graph | |||
return commitNodeType as CommitType; | |||
} | |||
function combineAndFilterStashCommits( | |||
commits: GitCommit[] | undefined, | |||
stashCommits: GitStashCommit[] | undefined, | |||
log: GitLog | undefined, | |||
): (GitCommit | GitStashCommit)[] { | |||
if (commits === undefined || log === undefined) { | |||
return []; | |||
} | |||
if (stashCommits === undefined) { | |||
return commits; | |||
} | |||
const stashCommitShas = stashCommits?.map(c => c.sha); | |||
const stashCommitShaSecondParents = stashCommits?.map(c => (c.parents.length > 1 ? c.parents[1] : undefined)); | |||
const filteredCommits = commits.filter( | |||
(commit: GitCommit): boolean => | |||
!stashCommitShas.includes(commit.sha) && !stashCommitShaSecondParents.includes(commit.sha), | |||
); | |||
const filteredStashCommits = stashCommits.filter((stashCommit: GitStashCommit): boolean => { | |||
if (!stashCommit.parents?.length) { | |||
return true; | |||
} | |||
const parentCommit: GitCommit | undefined = log.commits.get(stashCommit.parents[0]); | |||
return parentCommit !== undefined; | |||
}); | |||
// Remove the second parent, if existing, from each stash commit as it affects column processing | |||
for (const stashCommit of filteredStashCommits) { | |||
if (stashCommit.parents.length > 1) { | |||
stashCommit.parents.splice(1, 1); | |||
} | |||
} | |||
return [...filteredCommits, ...filteredStashCommits]; | |||
} | |||
function formatRepositories(repositories: Repository[]): GraphRepository[] { | |||
if (repositories.length === 0) { | |||
return repositories; | |||
} | |||
return repositories.map(({ formattedName, id, name, path }) => ({ | |||
formattedName: formattedName, | |||
id: id, | |||
name: name, | |||
path: path, | |||
})); | |||
} | |||
function formatLog(log: GitLog) { | |||
return { | |||
count: log.count, | |||
limit: log.limit, | |||
hasMore: log.hasMore, | |||
cursor: log.cursor, | |||
}; | |||
} |
@ -0,0 +1,82 @@ | |||
import { IpcCommandType, IpcNotificationType } from '../../../webviews/protocol'; | |||
export interface State { | |||
repositories?: GraphRepository[]; | |||
selectedRepository?: string; | |||
commits?: GraphCommit[]; | |||
config?: GraphConfig; | |||
remotes?: GraphRemote[]; | |||
tags?: GraphTag[]; | |||
branches?: GraphBranch[]; | |||
log?: GraphLog; | |||
nonce?: string; | |||
mixedColumnColors?: { [variable: string]: string }; | |||
} | |||
export interface GraphLog { | |||
count: number; | |||
limit?: number; | |||
hasMore: boolean; | |||
cursor?: string; | |||
} | |||
export type GraphRepository = Record<string, any>; | |||
export type GraphCommit = Record<string, any>; | |||
export type GraphRemote = Record<string, any>; | |||
export type GraphTag = Record<string, any>; | |||
export type GraphBranch = Record<string, any>; | |||
export interface GraphColumnConfig { | |||
width: number; | |||
} | |||
export interface GraphColumnConfigDictionary { | |||
[key: string]: GraphColumnConfig; | |||
} | |||
export interface GraphConfig { | |||
defaultLimit: number; | |||
pageLimit: number; | |||
columns?: GraphColumnConfigDictionary; | |||
columnColors: string[]; | |||
} | |||
export interface CommitListCallback { | |||
(state: State): void; | |||
} | |||
// Commands | |||
export interface ColumnChangeParams { | |||
name: string; | |||
config: GraphColumnConfig; | |||
} | |||
export const ColumnChangeCommandType = new IpcCommandType<ColumnChangeParams>('graph/column'); | |||
export interface MoreCommitsParams { | |||
limit?: number; | |||
} | |||
export const MoreCommitsCommandType = new IpcCommandType<MoreCommitsParams>('graph/moreCommits'); | |||
export interface SelectRepositoryParams { | |||
path: string; | |||
} | |||
export const SelectRepositoryCommandType = new IpcCommandType<SelectRepositoryParams>('graph/selectRepository'); | |||
// Notifications | |||
export interface DidChangeParams { | |||
state: State; | |||
} | |||
export const DidChangeNotificationType = new IpcNotificationType<DidChangeParams>('graph/didChange'); | |||
export interface DidChangeConfigParams { | |||
config: GraphConfig; | |||
} | |||
export const DidChangeConfigNotificationType = new IpcNotificationType<DidChangeConfigParams>('graph/didChangeConfig'); | |||
export interface DidChangeCommitsParams { | |||
commits: GraphCommit[]; | |||
log?: GraphLog; | |||
} | |||
export const DidChangeCommitsNotificationType = new IpcNotificationType<DidChangeCommitsParams>( | |||
'graph/didChangeCommits', | |||
); |
@ -0,0 +1,269 @@ | |||
import GraphContainer, { | |||
type CssVariables, | |||
type GraphColumnSetting as GKGraphColumnSetting, | |||
type GraphColumnsSettings as GKGraphColumnsSettings, | |||
type GraphRow, | |||
type GraphZoneType, | |||
type Head, | |||
type Remote, | |||
type Tag, | |||
} from '@gitkraken/gitkraken-components'; | |||
import React, { useEffect, useRef, useState } from 'react'; | |||
import type { | |||
CommitListCallback, | |||
GraphBranch, | |||
GraphColumnConfig, | |||
GraphCommit, | |||
GraphConfig, | |||
GraphRemote, | |||
GraphRepository, | |||
GraphTag, | |||
State, | |||
} from '../../../../plus/webviews/graph/protocol'; | |||
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; | |||
} | |||
// Copied from original pushed code of Miggy E. | |||
// TODO: review that code as I'm not sure if it is the correct way to do that in Gitlens side. | |||
// I suppose we need to use the GitLens themes here instead. | |||
const getCssVariables = (mixedColumnColors: CssVariables | undefined): CssVariables => { | |||
const body = document.body; | |||
const computedStyle = window.getComputedStyle(body); | |||
return { | |||
'--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, | |||
}; | |||
}; | |||
const getStyleProps = ( | |||
mixedColumnColors: CssVariables | undefined, | |||
): { cssVariables: CssVariables; themeOpacityFactor: number } => { | |||
const body = document.body; | |||
const computedStyle = window.getComputedStyle(body); | |||
return { | |||
cssVariables: getCssVariables(mixedColumnColors), | |||
themeOpacityFactor: parseInt(computedStyle.getPropertyValue('--graph-theme-opacity-factor')) || 1, | |||
}; | |||
}; | |||
const getGraphModel = ( | |||
gitCommits: GraphCommit[] = [], | |||
gitRemotes: GraphRemote[] = [], | |||
gitTags: GraphTag[] = [], | |||
gitBranches: GraphBranch[] = [], | |||
): GraphRow[] => { | |||
const graphRows: GraphRow[] = []; | |||
// console.log('gitCommits -> ', gitCommits); | |||
// console.log('gitRemotes -> ', gitRemotes); | |||
// console.log('gitTags -> ', gitTags); | |||
// console.log('gitBranches -> ', gitBranches); | |||
// TODO: review if that code is correct and see if we need to add more data | |||
for (const gitCommit of gitCommits) { | |||
const graphRemotes: Remote[] = gitBranches | |||
.filter((branch: GraphBranch) => branch.sha === gitCommit.sha && branch.remote) | |||
.map((branch: GraphBranch) => { | |||
const matchingRemote: GraphRemote | undefined = gitRemotes.find((remote: GraphRemote) => | |||
branch.name.startsWith(remote.name), | |||
); | |||
const matchingRemoteUrl: string | undefined = | |||
matchingRemote !== undefined && matchingRemote.urls.length > 0 ? matchingRemote.urls[0] : undefined; | |||
return { | |||
// If a matching remote is found, remove the remote name and slash from the branch name | |||
name: | |||
matchingRemote !== undefined ? branch.name.replace(`${matchingRemote.name}/`, '') : branch.name, | |||
url: matchingRemoteUrl, | |||
// TODO: Add avatarUrl support for remotes | |||
// avatarUrl: matchingRemote?.avatarUrl ?? undefined | |||
}; | |||
}); | |||
const graphHeads: Head[] = gitBranches | |||
.filter((branch: GraphBranch) => branch.sha === gitCommit.sha && branch.remote === false) | |||
.map((branch: GraphBranch) => { | |||
return { | |||
name: branch.name, | |||
isCurrentHead: branch.current, | |||
}; | |||
}); | |||
const graphTags: Tag[] = gitTags | |||
.filter((tag: GraphTag) => tag.sha === gitCommit.sha) | |||
.map((tag: GraphTag) => ({ | |||
name: tag.name, | |||
annotated: Boolean(tag.message), | |||
})); | |||
graphRows.push({ | |||
sha: gitCommit.sha, | |||
parents: gitCommit.parents, | |||
author: gitCommit.author.name, | |||
email: gitCommit.author.email, | |||
date: new Date(gitCommit.committer.date).getTime(), | |||
message: gitCommit.message, | |||
type: gitCommit.type, // TODO: review logic for stash, wip, etc | |||
heads: graphHeads, | |||
remotes: graphRemotes, | |||
tags: graphTags, | |||
}); | |||
} | |||
return graphRows; | |||
}; | |||
const defaultGraphColumnsSettings: GKGraphColumnsSettings = { | |||
commitAuthorZone: { width: 110 }, | |||
commitDateTimeZone: { width: 130 }, | |||
commitMessageZone: { width: 130 }, | |||
commitZone: { width: 170 }, | |||
refZone: { width: 150 }, | |||
}; | |||
const getGraphColSettingsModel = (config?: GraphConfig): 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); | |||
}); | |||
}; | |||
}; | |||
// eslint-disable-next-line @typescript-eslint/naming-convention | |||
export function GraphWrapper({ | |||
subscriber, | |||
commits = [], | |||
repositories = [], | |||
remotes = [], | |||
tags = [], | |||
branches = [], | |||
selectedRepository, | |||
config, | |||
log, | |||
// onSelectRepository, | |||
onColumnChange, | |||
onMoreCommits, | |||
nonce, | |||
mixedColumnColors, | |||
}: GraphWrapperProps) { | |||
const [graphList, setGraphList] = useState(getGraphModel(commits, remotes, tags, branches)); | |||
const [_reposList, setReposList] = useState(repositories); | |||
const [currentRepository, setCurrentRepository] = useState(selectedRepository); | |||
const [graphColSettings, setGraphColSettings] = useState(getGraphColSettingsModel(config)); | |||
const [logState, setLogState] = useState(log); | |||
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); | |||
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(getGraphModel(state.commits, state.remotes, state.tags, state.branches)); | |||
setReposList(state.repositories ?? []); | |||
setCurrentRepository(state.selectedRepository); | |||
setGraphColSettings(getGraphColSettingsModel(state.config)); | |||
setLogState(state.log); | |||
setIsLoading(false); | |||
setStyleProps(getStyleProps(state.mixedColumnColors)); | |||
} | |||
useEffect(() => { | |||
if (subscriber === undefined) { | |||
return; | |||
} | |||
return subscriber(transformData); | |||
}, []); | |||
const handleMoreCommits = () => { | |||
setIsLoading(true); | |||
onMoreCommits?.(); | |||
}; | |||
const handleOnColumnResized = (graphZoneType: GraphZoneType, columnSettings: GKGraphColumnSetting) => { | |||
if (onColumnChange !== undefined) { | |||
onColumnChange(graphZoneType, { width: columnSettings.width }); | |||
} | |||
}; | |||
return ( | |||
<main ref={mainRef} id="main" className="graph-app__main"> | |||
{currentRepository !== undefined ? ( | |||
<> | |||
{mainWidth !== undefined && mainHeight !== undefined && ( | |||
<GraphContainer | |||
columnsSettings={graphColSettings} | |||
cssVariables={styleProps.cssVariables} | |||
graphRows={graphList} | |||
height={mainHeight} | |||
hasMoreCommits={logState?.hasMore} | |||
isLoadingRows={isLoading} | |||
nonce={nonce} | |||
onColumnResized={handleOnColumnResized} | |||
onShowMoreCommits={handleMoreCommits} | |||
width={mainWidth} | |||
themeOpacityFactor={styleProps.themeOpacityFactor} | |||
/> | |||
)} | |||
</> | |||
) : ( | |||
<p>No repository is selected</p> | |||
)} | |||
</main> | |||
); | |||
} |
@ -0,0 +1,13 @@ | |||
<!DOCTYPE html> | |||
<html lang="en"> | |||
<head> | |||
<meta charset="utf-8" /> | |||
</head> | |||
<body class="graph-app preload"> | |||
<div id="root" class="graph-app__container"> | |||
<p>A repository must be selected.</p> | |||
</div> | |||
#{endOfBody} | |||
</body> | |||
</html> |
@ -0,0 +1,26 @@ | |||
@import '../../shared/base'; | |||
@import '../../shared/buttons'; | |||
@import '../../shared/icons'; | |||
// page stuff | |||
@import '../../shared/codicons'; | |||
@import '../../shared/utils'; | |||
@import '../../../../../node_modules/@gitkraken/gitkraken-components/dist/styles.css'; | |||
.graph-app { | |||
padding: 0; | |||
&__container { | |||
display: flex; | |||
flex-direction: column; | |||
height: 100vh; | |||
gap: 0.5rem; | |||
padding: 0 2px; | |||
} | |||
&__main { | |||
flex: 1 1 auto; | |||
overflow: hidden; | |||
} | |||
} |
@ -0,0 +1,174 @@ | |||
/*global document window*/ | |||
import type { CssVariables } from '@gitkraken/gitkraken-components'; | |||
import React from 'react'; | |||
import { render, unmountComponentAtNode } from 'react-dom'; | |||
import type { GraphConfig } from '../../../../config'; | |||
import type { | |||
CommitListCallback, | |||
GraphColumnConfig, | |||
GraphRepository, | |||
State} from '../../../../plus/webviews/graph/protocol'; | |||
import { | |||
ColumnChangeCommandType, | |||
DidChangeCommitsNotificationType, | |||
DidChangeConfigNotificationType, | |||
DidChangeNotificationType, | |||
MoreCommitsCommandType, | |||
SelectRepositoryCommandType | |||
} from '../../../../plus/webviews/graph/protocol'; | |||
import { debounce } from '../../../../system/function'; | |||
import { DidChangeConfigurationNotificationType, onIpc } from '../../../../webviews/protocol'; | |||
import { App } from '../../shared/appBase'; | |||
import { mix, opacity } from '../../shared/colors'; | |||
import { GraphWrapper } from './GraphWrapper'; | |||
import './graph.scss'; | |||
export class GraphApp extends App<State> { | |||
private callback?: CommitListCallback; | |||
private $menu?: HTMLElement; | |||
constructor() { | |||
super('GraphApp'); | |||
} | |||
protected override onBind() { | |||
const disposables = super.onBind?.() ?? []; | |||
console.log('GraphApp onBind log', this.state.log); | |||
const $root = document.getElementById('root'); | |||
if ($root != null) { | |||
render( | |||
<GraphWrapper | |||
subscriber={(callback: CommitListCallback) => this.registerEvents(callback)} | |||
onColumnChange={debounce( | |||
(name: string, settings: GraphColumnConfig) => this.onColumnChanged(name, settings), | |||
250, | |||
)} | |||
onSelectRepository={debounce((path: GraphRepository) => this.onRepositoryChanged(path), 250)} | |||
onMoreCommits={(...params) => this.onMoreCommits(...params)} | |||
{...this.state} | |||
/>, | |||
$root, | |||
); | |||
disposables.push({ | |||
dispose: () => unmountComponentAtNode($root), | |||
}); | |||
} | |||
return disposables; | |||
} | |||
protected override onMessageReceived(e: MessageEvent) { | |||
console.log('onMessageReceived', e); | |||
const msg = e.data; | |||
switch (msg.method) { | |||
case DidChangeNotificationType.method: | |||
this.log(`${this.appName}.onMessageReceived(${msg.id}): name=${msg.method}`); | |||
onIpc(DidChangeNotificationType, msg, params => { | |||
this.setState({ ...this.state, ...params.state }); | |||
this.refresh(this.state); | |||
}); | |||
break; | |||
case DidChangeCommitsNotificationType.method: | |||
this.log(`${this.appName}.onMessageReceived(${msg.id}): name=${msg.method}`); | |||
onIpc(DidChangeCommitsNotificationType, msg, params => { | |||
this.setState({ | |||
...this.state, | |||
commits: params.commits, | |||
log: params.log, | |||
}); | |||
this.refresh(this.state); | |||
}); | |||
break; | |||
case DidChangeConfigNotificationType.method: | |||
this.log(`${this.appName}.onMessageReceived(${msg.id}): name=${msg.method}`); | |||
onIpc(DidChangeConfigNotificationType, msg, params => { | |||
this.setState({ ...this.state, config: params.config }); | |||
this.refresh(this.state); | |||
}); | |||
break; | |||
case DidChangeConfigurationNotificationType.method: | |||
this.log(`${this.appName}.onMessageReceived(${msg.id}): name=${msg.method}`); | |||
onIpc(DidChangeConfigurationNotificationType, msg, params => { | |||
this.setState({ ...this.state, mixedColumnColors: this.getGraphColors(params.config.graph) }); | |||
this.refresh(this.state); | |||
}); | |||
break; | |||
default: | |||
super.onMessageReceived?.(e); | |||
} | |||
} | |||
private getGraphColors(config: GraphConfig | undefined): CssVariables { | |||
// this will be called on theme updated as well as on config updated since it is dependent on the column colors from config changes and the background color from the theme | |||
const body = document.body; | |||
const computedStyle = window.getComputedStyle(body); | |||
const bgColor = computedStyle.getPropertyValue('--color-background'); | |||
const columnColors = | |||
config?.columnColors != null | |||
? config.columnColors | |||
: ['#00bcd4', '#ff9800', '#9c27b0', '#2196f3', '#009688', '#ffeb3b', '#ff5722', '#795548']; | |||
const mixedGraphColors: CssVariables = {}; | |||
for (let i = 0; i < columnColors.length; i++) { | |||
mixedGraphColors[`--graph-color-${i}`] = columnColors[i]; | |||
mixedGraphColors[`--column-${i}-color`] = columnColors[i]; | |||
for (const mixInt of [15, 25, 45, 50]) { | |||
mixedGraphColors[`--graph-color-${i}-bg${mixInt}`] = mix(bgColor, columnColors[i], mixInt); | |||
} | |||
for (const mixInt of [10, 50]) { | |||
mixedGraphColors[`--graph-color-${i}-f${mixInt}`] = opacity(columnColors[i], mixInt); | |||
} | |||
} | |||
return mixedGraphColors; | |||
} | |||
protected override onThemeUpdated() { | |||
this.setState({ ...this.state, mixedColumnColors: this.getGraphColors(this.state.config) }); | |||
this.refresh(this.state); | |||
} | |||
private onColumnChanged(name: string, settings: GraphColumnConfig) { | |||
this.sendCommand(ColumnChangeCommandType, { | |||
name: name, | |||
config: settings, | |||
}); | |||
} | |||
private onRepositoryChanged(repo: GraphRepository) { | |||
this.sendCommand(SelectRepositoryCommandType, { | |||
path: repo.path, | |||
}); | |||
} | |||
private onMoreCommits(limit?: number) { | |||
this.sendCommand(MoreCommitsCommandType, { | |||
limit: limit, | |||
}); | |||
} | |||
private registerEvents(callback: CommitListCallback): () => void { | |||
this.callback = callback; | |||
return () => { | |||
this.callback = undefined; | |||
}; | |||
} | |||
private refresh(state: State) { | |||
if (this.callback !== undefined) { | |||
this.callback(state); | |||
} | |||
} | |||
} | |||
new GraphApp(); |