Browse Source

Adds Commit Graph webview (wip)

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
Eric Amodio 2 years ago
parent
commit
cc80d94f32
18 changed files with 1780 additions and 23 deletions
  1. +66
    -3
      package.json
  2. +11
    -0
      src/config.ts
  3. +1
    -0
      src/constants.ts
  4. +7
    -0
      src/container.ts
  5. +393
    -0
      src/plus/webviews/graph/graphWebview.ts
  6. +82
    -0
      src/plus/webviews/graph/protocol.ts
  7. +1
    -0
      src/storage.ts
  8. +269
    -0
      src/webviews/apps/plus/graph/GraphWrapper.tsx
  9. +13
    -0
      src/webviews/apps/plus/graph/graph.html
  10. +26
    -0
      src/webviews/apps/plus/graph/graph.scss
  11. +174
    -0
      src/webviews/apps/plus/graph/graph.tsx
  12. +2
    -1
      src/webviews/apps/shared/appBase.ts
  13. +17
    -0
      src/webviews/apps/shared/colors.ts
  14. +11
    -1
      src/webviews/apps/shared/theme.ts
  15. +1
    -0
      src/webviews/apps/tsconfig.json
  16. +9
    -5
      src/webviews/webviewBase.ts
  17. +15
    -5
      webpack.config.js
  18. +682
    -8
      yarn.lock

+ 66
- 3
package.json View File

@ -66,6 +66,7 @@
"onView:gitlens.views.commitDetails",
"onWebviewPanel:gitlens.welcome",
"onWebviewPanel:gitlens.settings",
"onWebviewPanel:gitlens.graph",
"onCommand:gitlens.plus.learn",
"onCommand:gitlens.plus.loginOrSignUp",
"onCommand:gitlens.plus.logout",
@ -73,6 +74,7 @@
"onCommand:gitlens.plus.manage",
"onCommand:gitlens.plus.purchase",
"onCommand:gitlens.getStarted",
"onCommand:gitlens.showGraphPage",
"onCommand:gitlens.showSettingsPage",
"onCommand:gitlens.showSettingsPage#views",
"onCommand:gitlens.showSettingsPage#autolinks",
@ -3003,6 +3005,51 @@
}
},
{
"id": "graph",
"title": "Graph",
"order": 124,
"properties": {
"gitlens.graph.defaultLimit": {
"type": "number",
"default": 500,
"markdownDescription": "Specifies the default number of items to show in the graph list. Use 0 to specify no limit",
"scope": "window",
"order": 10
},
"gitlens.graph.pageLimit": {
"type": "number",
"default": 200,
"markdownDescription": "Specifies the number of items to show in a each page when paginating a graph list. Use 0 to specify no limit",
"scope": "window",
"order": 20
},
"gitlens.graph.columnColors": {
"type": "array",
"items": {
"type": "string",
"pattern": "^#(?:[0-9a-fA-F]{3,4}){1,2}$"
},
"default": [
"#15a0bf",
"#0669f7",
"#8e00c2",
"#c517b6",
"#d90171",
"#cd0101",
"#f25d2e",
"#f2ca33",
"#7bd938",
"#2ece9d"
],
"markdownDescription": "Specifies the colors used for the different columns in the graph",
"scope": "window",
"order": 30,
"maxItems": 10,
"minItems": 10
}
}
},
{
"id": "advanced",
"title": "Advanced",
"order": 1000,
@ -3777,6 +3824,11 @@
"category": "GitLens"
},
{
"command": "gitlens.showGraphPage",
"title": "Show Commit Graph",
"category": "GitLens"
},
{
"command": "gitlens.showSettingsPage",
"title": "Open Settings",
"category": "GitLens",
@ -6141,6 +6193,10 @@
"when": "gitlens:debugging"
},
{
"command": "gitlens.showGraphPage",
"when": "gitlens:enabled"
},
{
"command": "gitlens.showSettingsPage#views",
"when": "false"
},
@ -11264,8 +11320,8 @@
"bundle": "webpack --mode production",
"clean": "npx rimraf dist out .vscode-test .vscode-test-web .eslintcache* tsconfig*.tsbuildinfo",
"copy:images": "webpack --config webpack.config.images.js",
"lint": "eslint src/**/*.ts --fix",
"lint:webviews": "eslint src/webviews/apps/**/*.ts --fix",
"lint": "eslint \"src/**/*.ts?(x)\" --fix",
"lint:webviews": "eslint \"src/webviews/apps/**/*.ts?(x)\" --fix",
"package": "vsce package --yarn",
"package-insiders": "yarn run patch-insiders && yarn run package",
"patch-insiders": "node ./scripts/applyPatchForInsiders.js",
@ -11290,12 +11346,14 @@
"vscode:prepublish": "yarn run bundle"
},
"dependencies": {
"@gitkraken/gitkraken-components": "1.0.0-rc.1",
"@octokit/core": "4.0.4",
"@vscode/codicons": "0.0.32",
"@vscode/webview-ui-toolkit": "1.0.0",
"ansi-regex": "6.0.1",
"billboard.js": "3.5.1",
"chroma-js": "2.4.2",
"crypto-browserify": "3.12.0",
"https-proxy-agent": "5.0.1",
"iconv-lite": "0.6.3",
"lit": "2.2.8",
@ -11303,7 +11361,10 @@
"md5.js": "1.3.5",
"node-fetch": "2.6.7",
"path-browserify": "1.0.1",
"sortablejs": "1.15.0"
"react": "16.8.4",
"react-dom": "16.8.4",
"sortablejs": "1.15.0",
"stream-browserify": "3.0.0"
},
"devDependencies": {
"@squoosh/lib": "0.4.0",
@ -11312,6 +11373,8 @@
"@types/lodash-es": "4.17.6",
"@types/mocha": "9.1.1",
"@types/node": "16.11.47",
"@types/react": "17.0.47",
"@types/react-dom": "17.0.17",
"@types/sortablejs": "1.13.0",
"@types/uuid": "8.3.4",
"@types/vscode": "1.69.0",

+ 11
- 0
src/config.ts View File

@ -57,6 +57,7 @@ export interface Config {
skipConfirmations: string[];
sortBy: GitCommandSorting;
};
graph: GraphConfig;
heatmap: {
ageThreshold: number;
coldColor: string;
@ -372,6 +373,16 @@ export interface AdvancedConfig {
similarityThreshold: number | null;
}
export interface GraphColumnConfig {
width: number;
}
export interface GraphConfig {
defaultLimit: number;
pageLimit: number;
columnColors: string[];
}
export interface CodeLensConfig {
authors: {
enabled: boolean;

+ 1
- 0
src/constants.ts View File

@ -202,6 +202,7 @@ export const enum Commands {
ShowCommitDetailsView = 'gitlens.showCommitDetailsView',
ShowTimelinePage = 'gitlens.showTimelinePage',
ShowTimelineView = 'gitlens.showTimelineView',
ShowGraphPage = 'gitlens.showGraphPage',
ShowWelcomePage = 'gitlens.showWelcomePage',
StashApply = 'gitlens.stashApply',
StashSave = 'gitlens.stashSave',

+ 7
- 0
src/container.ts View File

@ -21,6 +21,7 @@ import { IntegrationAuthenticationService } from './plus/integrationAuthenticati
import { SubscriptionAuthenticationProvider } from './plus/subscription/authenticationProvider';
import { ServerConnection } from './plus/subscription/serverConnection';
import { SubscriptionService } from './plus/subscription/subscriptionService';
import { GraphWebview } from './plus/webviews/graph/graphWebview';
import { TimelineWebview } from './plus/webviews/timeline/timelineWebview';
import { TimelineWebviewView } from './plus/webviews/timeline/timelineWebviewView';
import { StatusBarController } from './statusbar/statusBarController';
@ -172,6 +173,7 @@ export class Container {
context.subscriptions.push((this._timelineWebview = new TimelineWebview(this)));
context.subscriptions.push((this._welcomeWebview = new WelcomeWebview(this)));
context.subscriptions.push((this._rebaseEditor = new RebaseEditorProvider(this)));
context.subscriptions.push((this._graphWebview = new GraphWebview(this)));
context.subscriptions.push(new ViewFileDecorationProvider());
@ -494,6 +496,11 @@ export class Container {
return this._settingsWebview;
}
private _graphWebview: GraphWebview;
get graphWebview() {
return this._graphWebview;
}
private _stashesView: StashesView | undefined;
get stashesView() {
if (this._stashesView == null) {

+ 393
- 0
src/plus/webviews/graph/graphWebview.ts View File

@ -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,
};
}

+ 82
- 0
src/plus/webviews/graph/protocol.ts View File

@ -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',
);

+ 1
- 0
src/storage.ts View File

@ -121,6 +121,7 @@ export const enum WorkspaceStorageKeys {
ViewsSearchAndCompareKeepResults = 'gitlens:views:searchAndCompare:keepResults',
ViewsSearchAndComparePinnedItems = 'gitlens:views:searchAndCompare:pinned',
ViewsCommitDetailsAutolinksExpanded = 'gitlens:views:commitDetails:autolinksExpanded',
GraphColumns = 'gitlens:graph:columns',
Deprecated_DisallowConnectionPrefix = 'gitlens:disallow:connection:',
Deprecated_PinnedComparisons = 'gitlens:pinned:comparisons',

+ 269
- 0
src/webviews/apps/plus/graph/GraphWrapper.tsx View File

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

+ 13
- 0
src/webviews/apps/plus/graph/graph.html View File

@ -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>

+ 26
- 0
src/webviews/apps/plus/graph/graph.scss View File

@ -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;
}
}

+ 174
- 0
src/webviews/apps/plus/graph/graph.tsx View File

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

+ 2
- 1
src/webviews/apps/shared/appBase.ts View File

@ -38,7 +38,7 @@ export abstract class App {
// this.log(`${this.appName}(${this.state ? JSON.stringify(this.state) : ''})`);
this._api = acquireVsCodeApi();
initializeAndWatchThemeColors();
initializeAndWatchThemeColors(this.onThemeUpdated?.bind(this));
requestAnimationFrame(() => {
this.log(`${this.appName}.initializing`);
@ -66,6 +66,7 @@ export abstract class App {
protected onBind?(): Disposable[];
protected onInitialized?(): void;
protected onMessageReceived?(e: MessageEvent): void;
protected onThemeUpdated?(): void;
private bindDisposables: Disposable[] | undefined;
protected bind() {

+ 17
- 0
src/webviews/apps/shared/colors.ts View File

@ -29,6 +29,23 @@ export function opacity(color: string, percentage: number) {
return `rgba(${r}, ${g}, ${b}, ${a * (percentage / 100)})`;
}
export function mix(color1: string, color2: string, percentage: number) {
const rgba1 = toRgba(color1);
const rgba2 = toRgba(color2);
if (rgba1 == null || rgba2 == null) return color1;
const [r1, g1, b1, a1] = rgba1;
const [r2, g2, b2, a2] = rgba2;
return `rgba(${mixChannel(r1, r2, percentage)}, ${mixChannel(g1, g2, percentage)}, ${mixChannel(
b1,
b2,
percentage,
)}, ${mixChannel(a1, a2, percentage)})`;
}
const mixChannel = (channel1: number, channel2: number, percentage: number) => {
return channel1 + ((channel2 - channel1) * percentage) / 100;
};
export function toRgba(color: string) {
color = color.trim();

+ 11
- 1
src/webviews/apps/shared/theme.ts View File

@ -1,7 +1,7 @@
/*global window document MutationObserver*/
import { darken, lighten, opacity } from './colors';
export function initializeAndWatchThemeColors() {
export function initializeAndWatchThemeColors(callback?: () => void) {
const onColorThemeChanged = () => {
const body = document.body;
const computedStyle = window.getComputedStyle(body);
@ -93,9 +93,19 @@ export function initializeAndWatchThemeColors() {
bodyStyle.setProperty('--color-hover-foreground', color);
color = computedStyle.getPropertyValue('--vscode-editorHoverWidget-statusBarBackground').trim();
bodyStyle.setProperty('--color-hover-statusBarBackground', color);
// graph-specific colors
const isLightTheme =
body.className.includes('vscode-light') || body.className.includes('vscode-high-contrast-light');
color = computedStyle.getPropertyValue('--vscode-editor-background').trim();
bodyStyle.setProperty('--graph-panel-bg', isLightTheme ? darken(color, 5) : lighten(color, 5));
bodyStyle.setProperty('--graph-theme-opacity-factor', isLightTheme ? '0.5' : '1');
callback?.();
};
const observer = new MutationObserver(onColorThemeChanged);
observer.observe(document.body, { attributes: true, attributeFilter: ['class'] });
onColorThemeChanged();

+ 1
- 0
src/webviews/apps/tsconfig.json View File

@ -1,6 +1,7 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react",
"lib": ["dom", "dom.iterable", "es2020"],
"outDir": "../../",
"paths": {

+ 9
- 5
src/webviews/webviewBase.ts View File

@ -104,6 +104,11 @@ export abstract class WebviewBase implements Disposable {
}
}
private readonly _cspNonce = getNonce();
protected get cspNonce(): string {
return this._cspNonce;
}
protected onInitializing?(): Disposable[] | undefined;
protected onReady?(): void;
protected onMessageReceived?(e: IpcMessage): void;
@ -188,7 +193,6 @@ export abstract class WebviewBase implements Disposable {
]);
const cspSource = webview.cspSource;
const cspNonce = getNonce();
const root = webview.asWebviewUri(this.container.context.extensionUri).toString();
const webRoot = webview.asWebviewUri(webRootUri).toString();
@ -204,9 +208,9 @@ export abstract class WebviewBase implements Disposable {
case 'endOfBody':
return `${
bootstrap != null
? `<script type="text/javascript" nonce="${cspNonce}">window.bootstrap=${JSON.stringify(
bootstrap,
)};</script>`
? `<script type="text/javascript" nonce="${
this.cspNonce
}">window.bootstrap=${JSON.stringify(bootstrap)};</script>`
: ''
}${endOfBody ?? ''}`;
case 'placement':
@ -214,7 +218,7 @@ export abstract class WebviewBase implements Disposable {
case 'cspSource':
return cspSource;
case 'cspNonce':
return cspNonce;
return this.cspNonce;
case 'root':
return root;
case 'webroot':

+ 15
- 5
webpack.config.js View File

@ -57,13 +57,14 @@ function getExtensionConfig(target, mode, env) {
async: false,
eslint: {
enabled: true,
files: 'src/**/*.ts',
files: 'src/**/*.ts?(x)',
options: {
// cache: true,
// cacheLocation: path.join(
// __dirname,
// target === 'webworker' ? '.eslintcache.browser' : '.eslintcache',
// ),
fix: mode !== 'production',
overrideConfigFile: path.join(
__dirname,
target === 'webworker' ? '.eslintrc.browser.json' : '.eslintrc.json',
@ -173,7 +174,7 @@ function getExtensionConfig(target, mode, env) {
loader: 'esbuild-loader',
options: {
implementation: esbuild,
loader: 'ts',
loader: 'tsx',
target: ['es2020', 'chrome91', 'node14.16'],
tsconfigRaw: resolveTSConfig(
path.join(
@ -248,8 +249,11 @@ function getWebviewsConfig(mode, env) {
async: false,
eslint: {
enabled: true,
files: path.join(basePath, '**', '*.ts'),
// options: { cache: true },
files: path.join(basePath, '**', '*.ts?(x)'),
options: {
// cache: true,
fix: mode !== 'production',
},
},
formatter: 'basic',
typescript: {
@ -258,6 +262,7 @@ function getWebviewsConfig(mode, env) {
}),
new MiniCssExtractPlugin({ filename: '[name].css' }),
getHtmlPlugin('commitDetails', false, mode, env),
getHtmlPlugin('graph', true, mode, env),
getHtmlPlugin('home', false, mode, env),
getHtmlPlugin('rebase', false, mode, env),
getHtmlPlugin('settings', false, mode, env),
@ -302,6 +307,7 @@ function getWebviewsConfig(mode, env) {
context: basePath,
entry: {
commitDetails: './commitDetails/commitDetails.ts',
graph: './plus/graph/graph.tsx',
home: './home/home.ts',
rebase: './rebase/rebase.ts',
settings: './settings/settings.ts',
@ -367,7 +373,7 @@ function getWebviewsConfig(mode, env) {
loader: 'esbuild-loader',
options: {
implementation: esbuild,
loader: 'ts',
loader: 'tsx',
target: 'es2020',
tsconfigRaw: resolveTSConfig(path.join(basePath, 'tsconfig.json')),
},
@ -411,6 +417,10 @@ function getWebviewsConfig(mode, env) {
},
extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'],
modules: [basePath, 'node_modules'],
fallback: {
crypto: require.resolve('crypto-browserify'),
stream: require.resolve('stream-browserify'),
},
},
plugins: plugins,
infrastructureLogging: {

+ 682
- 8
yarn.lock
File diff suppressed because it is too large
View File


Loading…
Cancel
Save