Browse Source

Reworks Graph data model

- Provides GraphRows directly from the extension to the webview
- Only serializes row diffs to the webview when paging
- Adds commit avatars
- Fixes stale history when shown (after the first time)
main
Eric Amodio 2 years ago
parent
commit
7f84e4fa4e
7 changed files with 439 additions and 388 deletions
  1. +8
    -0
      src/env/node/git/localGitProvider.ts
  2. +3
    -0
      src/git/models/log.ts
  3. +9
    -1
      src/plus/github/githubGitProvider.ts
  4. +330
    -282
      src/plus/webviews/graph/graphWebview.ts
  5. +28
    -10
      src/plus/webviews/graph/protocol.ts
  6. +6
    -81
      src/webviews/apps/plus/graph/GraphWrapper.tsx
  7. +55
    -14
      src/webviews/apps/plus/graph/graph.tsx

+ 8
- 0
src/env/node/git/localGitProvider.ts View File

@ -2242,6 +2242,14 @@ export class LocalGitProvider implements GitProvider, Disposable {
count: commits.size,
limit: moreUntil == null ? (log.limit ?? 0) + moreLimit : undefined,
hasMore: moreUntil == null ? moreLog.hasMore : true,
pagedCommits: () => {
// Remove any duplicates
for (const sha of log.commits.keys()) {
moreLog.commits.delete(sha);
}
return moreLog.commits;
},
previousCursor: last(log.commits)?.[0],
query: (limit: number | undefined) => this.getLog(log.repoPath, { ...options, limit: limit }),
};
mergedLog.more = this.getLogMoreFn(mergedLog, options);

+ 3
- 0
src/git/models/log.ts View File

@ -13,6 +13,9 @@ export interface GitLog {
readonly hasMore: boolean;
readonly cursor?: string;
readonly pagedCommits?: () => Map<string, GitCommit>;
readonly previousCursor?: string;
query?(limit: number | undefined): Promise<GitLog | undefined>;
more?(limit: number | { until?: string } | undefined): Promise<GitLog | undefined>;
}

+ 9
- 1
src/plus/github/githubGitProvider.ts View File

@ -59,7 +59,7 @@ import type { LogScope } from '../../logger';
import { Logger } from '../../logger';
import { gate } from '../../system/decorators/gate';
import { debug, getLogScope, log } from '../../system/decorators/log';
import { filterMap, some } from '../../system/iterable';
import { filterMap, last, some } from '../../system/iterable';
import { isAbsolute, isFolderGlob, maybeUri, normalizePath, relative } from '../../system/path';
import { getSettledValue } from '../../system/promise';
import type { CachedBlame, CachedLog } from '../../trackers/gitDocumentTracker';
@ -1358,6 +1358,14 @@ export class GitHubGitProvider implements GitProvider, Disposable {
limit: moreUntil == null ? (log.limit ?? 0) + moreLimit : undefined,
hasMore: moreUntil == null ? moreLog.hasMore : true,
cursor: moreLog.cursor,
pagedCommits: () => {
// Remove any duplicates
for (const sha of log.commits.keys()) {
moreLog.commits.delete(sha);
}
return moreLog.commits;
},
previousCursor: last(log.commits)?.[0],
query: log.query,
};
mergedLog.more = this.getLogMoreFn(mergedLog, options);

+ 330
- 282
src/plus/webviews/graph/graphWebview.ts View File

@ -1,4 +1,4 @@
import type { CommitType } from '@gitkraken/gitkraken-components';
import type { CommitType, GraphRow, Head, Remote, Tag } from '@gitkraken/gitkraken-components';
import { commitNodeType, mergeNodeType, stashNodeType } from '@gitkraken/gitkraken-components';
import type { ColorTheme, ConfigurationChangeEvent, Disposable, Event, StatusBarItem } from 'vscode';
import { ColorThemeKind, EventEmitter, MarkdownString, StatusBarAlignment, Uri, ViewColumn, window } from 'vscode';
@ -16,14 +16,20 @@ import type { GitLog } from '../../../git/models/log';
import type { GitRemote } from '../../../git/models/remote';
import type { Repository, RepositoryChangeEvent } from '../../../git/models/repository';
import { RepositoryChange, RepositoryChangeComparisonMode } from '../../../git/models/repository';
import type { GitStash } from '../../../git/models/stash';
import type { GitTag } from '../../../git/models/tag';
import { debug } from '../../../system/decorators/log';
import type { Deferrable } from '../../../system/function';
import { debounce } from '../../../system/function';
import { filter, filterMap, union } from '../../../system/iterable';
import { updateRecordValue } from '../../../system/object';
import { getSettledValue } from '../../../system/promise';
import { RepositoryFolderNode } from '../../../views/nodes/viewNode';
import type { IpcMessage } from '../../../webviews/protocol';
import { onIpc } from '../../../webviews/protocol';
import { WebviewBase } from '../../../webviews/webviewBase';
import { ensurePlusFeaturesEnabled } from '../../subscription/utils';
import type { GraphCommit, GraphCompositeConfig, GraphRemote, GraphRepository, State } from './protocol';
import type { GraphCompositeConfig, GraphLog, GraphRepository, State } from './protocol';
import {
DidChangeCommitsNotificationType,
DidChangeGraphConfigurationNotificationType,
@ -45,12 +51,37 @@ export class GraphWebview extends WebviewBase {
return this._onDidChangeSelection.event;
}
private _repository?: Repository;
get repository(): Repository | undefined {
return this._repository;
}
set repository(value: Repository | undefined) {
if (this._repository === value) return;
this._repositoryEventsDisposable?.dispose();
this._repository = value;
this._etagRepository = value?.etag;
this._repositoryLog = undefined;
if (value != null) {
this._repositoryEventsDisposable = value.onDidChange(this.onRepositoryChanged, this);
}
this.updateState();
}
private _selection: readonly GitCommit[] | undefined;
get selection(): readonly GitCommit[] | undefined {
return this._selection;
}
private _etagRepository?: number;
private _repositoryEventsDisposable: Disposable | undefined;
private _repositoryLog?: GitLog;
private _statusBarItem: StatusBarItem | undefined;
private _theme: ColorTheme | undefined;
private selectedRepository?: Repository;
private selection?: GitCommit[];
private currentLog?: GitLog;
private previewBanner?: boolean;
constructor(container: Container) {
@ -83,13 +114,13 @@ export class GraphWebview extends WebviewBase {
if (context.type === 'scm' && context.scm.rootUri != null) {
const repository = this.container.git.getRepository(context.scm.rootUri);
if (repository != null) {
this.selectedRepository = repository;
this.repository = repository;
}
} else if (context.type === 'viewItem' && context.node instanceof RepositoryFolderNode) {
this.selectedRepository = context.node.repo;
this.repository = context.node.repo;
}
if (this.selectedRepository != null) {
if (this.repository != null) {
void this.refresh();
}
}
@ -97,7 +128,10 @@ export class GraphWebview extends WebviewBase {
return super.show(column, ...args);
}
private _theme: ColorTheme | undefined;
protected override async includeBootstrap(): Promise<State> {
return this.getState();
}
protected override onInitializing(): Disposable[] | undefined {
this._theme = window.activeColorTheme;
return [window.onDidChangeActiveColorTheme(this.onThemeChanged, this)];
@ -109,13 +143,13 @@ export class GraphWebview extends WebviewBase {
onIpc(DismissPreviewCommandType, e, () => this.dismissPreview());
break;
case GetMoreCommitsCommandType.method:
onIpc(GetMoreCommitsCommandType, e, params => this.moreCommits(params.limit));
onIpc(GetMoreCommitsCommandType, e, params => this.onGetMoreCommits(params.limit));
break;
case UpdateColumnCommandType.method:
onIpc(UpdateColumnCommandType, e, params => this.changeColumn(params.name, params.config));
onIpc(UpdateColumnCommandType, e, params => this.onColumnUpdated(params.name, params.config));
break;
case UpdateSelectedRepositoryCommandType.method:
onIpc(UpdateSelectedRepositoryCommandType, e, params => this.changeRepository(params.path));
onIpc(UpdateSelectedRepositoryCommandType, e, params => this.onRepositorySelectionChanged(params.path));
break;
case UpdateSelectionCommandType.method:
onIpc(UpdateSelectionCommandType, e, params => this.onSelectionChanged(params.selection));
@ -129,6 +163,13 @@ export class GraphWebview extends WebviewBase {
}
}
protected override onVisibilityChanged(visible: boolean): void {
if (visible && this.repository != null && this.repository.etag !== this._etagRepository) {
this._repositoryLog = undefined;
void this.refresh();
}
}
private onConfigurationChanged(e?: ConfigurationChangeEvent) {
if (configuration.changed(e, 'graph.statusBar.enabled') || configuration.changed(e, 'plusFeatures.enabled')) {
const enabled = configuration.get('graph.statusBar.enabled') && configuration.get('plusFeatures.enabled');
@ -157,8 +198,30 @@ export class GraphWebview extends WebviewBase {
}
if (e != null && configuration.changed(e, 'graph')) {
void this.notifyDidChangeConfig();
this.updateState();
}
}
private onRepositoryChanged(e: RepositoryChangeEvent) {
if (
!e.changed(
RepositoryChange.Config,
RepositoryChange.Heads,
RepositoryChange.Index,
RepositoryChange.Remotes,
RepositoryChange.RemoteProviders,
RepositoryChange.Stash,
RepositoryChange.Status,
RepositoryChange.Tags,
RepositoryChange.Unknown,
RepositoryChangeComparisonMode.Any,
)
) {
return;
}
this._repositoryLog = undefined;
this.updateState();
}
private onThemeChanged(theme: ColorTheme) {
@ -172,7 +235,7 @@ export class GraphWebview extends WebviewBase {
}
this._theme = theme;
void this.notifyDidChangeState();
this.updateState();
}
private dismissPreview() {
@ -183,44 +246,43 @@ export class GraphWebview extends WebviewBase {
void this.container.storage.storeWorkspace('graph:banners:dismissed', banners);
}
private changeColumn(name: string, config: GraphColumnConfig) {
const columns = this.container.storage.getWorkspace('graph:columns') ?? {};
columns[name] = config;
private onColumnUpdated(name: string, config: GraphColumnConfig) {
let columns = this.container.storage.getWorkspace('graph:columns');
columns = updateRecordValue(columns, name, config);
void this.container.storage.storeWorkspace('graph:columns', columns);
void this.notifyDidChangeConfig();
void this.notifyDidChangeGraphConfiguration();
}
private async moreCommits(limit?: number) {
if (this.currentLog?.more !== undefined) {
private async onGetMoreCommits(limit?: number) {
if (this._repositoryLog?.more != null) {
const { defaultItemLimit, pageItemLimit } = this.getConfig();
const nextLog = await this.currentLog.more(limit ?? pageItemLimit ?? defaultItemLimit);
if (nextLog !== undefined) {
this.currentLog = nextLog;
const nextLog = await this._repositoryLog.more(limit ?? pageItemLimit ?? defaultItemLimit);
if (nextLog != null) {
this._repositoryLog = 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;
private onRepositorySelectionChanged(path: string) {
if (this.repository?.path !== path) {
this.repository = this.container.git.getRepository(path);
}
void this.notifyDidChangeState();
}
private async onSelectionChanged(selection: GraphCommit[]) {
const ref = selection[0]?.sha;
private async onSelectionChanged(selection: string[]) {
const ref = selection[0];
let commits: GitCommit[] | undefined;
if (ref != null) {
const commit = await this.selectedRepository?.getCommit(ref);
const commit = await this.repository?.getCommit(ref);
if (commit != null) {
commits = [commit];
}
}
this.selection = commits;
this._selection = commits;
this._onDidChangeSelection.fire({ selection: commits ?? [] });
if (commits == null) return;
@ -228,141 +290,132 @@ export class GraphWebview extends WebviewBase {
void GitActions.Commit.showDetailsView(commits[0], { pin: true, preserveFocus: true });
}
private async notifyDidChangeConfig() {
return this.notify(DidChangeGraphConfigurationNotificationType, {
config: this.getConfig(),
});
}
private _notifyDidChangeStateDebounced: Deferrable<() => void> | undefined = undefined;
private async notifyDidChangeCommits() {
const [commitsAndLog, stashCommits] = await Promise.all([this.getCommits(), this.getStashCommits()]);
@debug()
private updateState(immediate: boolean = false) {
if (!this.isReady || !this.visible) return;
const log = commitsAndLog?.log;
const combinedCommitsWithFilteredStashes = combineAndFilterStashCommits(
commitsAndLog?.commits,
stashCommits,
log,
);
if (immediate) {
void this.notifyDidChangeState();
return;
}
return this.notify(DidChangeCommitsNotificationType, {
commits: formatCommits(combinedCommitsWithFilteredStashes),
log: log != null ? formatLog(log) : undefined,
});
if (this._notifyDidChangeStateDebounced == null) {
this._notifyDidChangeStateDebounced = debounce(this.notifyDidChangeState.bind(this), 500);
}
this._notifyDidChangeStateDebounced();
}
@debug()
private async notifyDidChangeState() {
if (!this.isReady || !this.visible) return false;
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;
@debug()
private async notifyDidChangeGraphConfiguration() {
if (!this.isReady || !this.visible) return false;
return this.notify(DidChangeGraphConfigurationNotificationType, {
config: this.getConfig(),
});
}
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;
}
@debug()
private async notifyDidChangeCommits() {
if (!this.isReady || !this.visible) return false;
const { defaultItemLimit, pageItemLimit } = this.getConfig();
return this.container.git.getLog(repository.uri, {
all: true,
limit: pageItemLimit ?? defaultItemLimit,
const data = await this.getGraphData(true);
return this.notify(DidChangeCommitsNotificationType, {
rows: data.rows,
log: formatLog(data.log),
previousCursor: data.log?.previousCursor,
});
}
private async getCommits(): Promise<{ log: GitLog; commits: GitCommit[] } | undefined> {
if (this.selectedRepository === undefined) {
return undefined;
}
private async getGraphData(paging: boolean = false): Promise<{ log: GitLog | undefined; rows: GraphRow[] }> {
const [logResult, stashResult, branchesResult, tagsResult, remotesResult] = await Promise.allSettled([
this.getLog(),
this.getStash(),
this.getBranches(),
this.getTags(),
this.getRemotes(),
]);
if (this.currentLog === undefined) {
const log = await this.getLog(this.selectedRepository);
if (log?.commits === undefined) {
return undefined;
}
this.currentLog = log;
}
const log = getSettledValue(logResult);
const combinedCommits = combineLogAndStash(log, getSettledValue(stashResult), paging);
if (this.currentLog?.commits === undefined) {
return undefined;
}
const rows = await convertToRows(
combinedCommits,
getSettledValue(branchesResult) ?? [],
getSettledValue(tagsResult) ?? [],
getSettledValue(remotesResult) ?? [],
icon =>
this._panel?.webview
.asWebviewUri(
Uri.joinPath(
this.container.context.extensionUri,
`images/${isLightTheme(window.activeColorTheme) ? 'light' : 'dark'}/icon-${icon}.svg`,
),
)
.toString(),
);
return {
log: this.currentLog,
commits: Array.from(this.currentLog.commits.values()),
log: log,
rows: rows,
};
}
private async getRemotes(): Promise<GitRemote[] | undefined> {
if (this.selectedRepository === undefined) {
return undefined;
}
private async getLog(): Promise<GitLog | undefined> {
if (this.repository == null) return undefined;
return this.selectedRepository.getRemotes();
}
if (this._repositoryLog == null) {
const { defaultItemLimit, pageItemLimit } = this.getConfig();
const log = await this.container.git.getLog(this.repository.uri, {
all: true,
limit: defaultItemLimit ?? pageItemLimit,
});
if (log?.commits == null) return undefined;
private async getTags(): Promise<GitTag[] | undefined> {
if (this.selectedRepository === undefined) {
return undefined;
this._repositoryLog = log;
}
const tags = await this.container.git.getTags(this.selectedRepository.uri);
if (tags === undefined) {
return undefined;
}
if (this._repositoryLog?.commits == null) return undefined;
return Array.from(tags.values);
return this._repositoryLog;
}
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;
const branches = await this.repository?.getBranches();
if (branches?.paging?.more) {
debugger;
// TODO@eamodio - implement paging
}
return Array.from(branches.values);
return 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;
private async getTags(): Promise<GitTag[] | undefined> {
const tags = await this.repository?.getTags();
if (tags?.paging?.more) {
debugger;
// TODO@eamodio - implement paging
}
return Array.from(stash?.commits?.values());
return tags?.values;
}
private pickRepository(repositories: Repository[]): Repository | undefined {
if (repositories.length === 0) {
return undefined;
}
if (repositories.length === 1) {
return repositories[0];
}
const bestRepo = this.container.git.getBestRepository(window.activeTextEditor);
if (bestRepo != null) {
return bestRepo;
}
private async getRemotes(): Promise<GitRemote[] | undefined> {
return this.repository?.getRemotes();
}
return repositories[0];
private async getStash(): Promise<GitStash | undefined> {
// TODO@eamodio look into using `git log -g stash` to get stashes with the commits
return this.repository?.getStash();
}
private getConfig(): GraphCompositeConfig {
@ -374,115 +427,175 @@ export class GraphWebview extends WebviewBase {
return config;
}
private onRepositoryChanged(e: RepositoryChangeEvent) {
if (
!e.changed(
RepositoryChange.Config,
RepositoryChange.Heads,
RepositoryChange.Index,
RepositoryChange.Remotes,
RepositoryChange.RemoteProviders,
RepositoryChange.Stash,
RepositoryChange.Status,
RepositoryChange.Tags,
RepositoryChange.Unknown,
RepositoryChangeComparisonMode.Any,
)
) {
return;
}
this.currentLog = undefined;
void this.notifyDidChangeState();
}
private async getState(): Promise<State> {
const repositories = this.getRepos();
if (repositories.length === 0) {
return {
repositories: [],
};
}
if (this.container.git.repositoryCount === 0) return { repositories: [] };
if (this.previewBanner == null) {
const banners = this.container.storage.getWorkspace('graph:banners:dismissed');
this.previewBanner = !banners?.['preview'];
}
if (this.selectedRepository === undefined) {
const idealRepo = this.pickRepository(repositories);
this.selectedRepository = idealRepo;
this._repositoryEventsDisposable?.dispose();
if (this.selectedRepository != null) {
this._repositoryEventsDisposable = this.selectedRepository.onDidChange(this.onRepositoryChanged, this);
}
if (this.repository == null) {
this.repository = this.container.git.getBestRepositoryOrFirst();
}
if (this.selectedRepository !== undefined) {
this.title = `${this.originalTitle}: ${this.selectedRepository.formattedName}`;
if (this.repository != null) {
this.title = `${this.originalTitle}: ${this.repository.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,
);
const theme = window.activeColorTheme;
const data = await this.getGraphData(false);
return {
previewBanner: this.previewBanner,
repositories: formatRepositories(repositories),
selectedRepository: this.selectedRepository?.path,
commits: formatCommits(combinedCommitsWithFilteredStashes),
remotes: formatRemotes(remotes, icon =>
this._panel?.webview
.asWebviewUri(
Uri.joinPath(
this.container.context.extensionUri,
`images/${isLightTheme(theme) ? 'light' : 'dark'}/icon-${icon}.svg`,
),
)
.toString(),
),
branches: branches, // TODO: add a format function
tags: tags, // TODO: add a format function
repositories: formatRepositories(this.container.git.openRepositories),
selectedRepository: this.repository?.path,
rows: data.rows,
log: formatLog(data.log),
config: this.getConfig(),
log: log != null ? formatLog(log) : undefined,
nonce: this.cspNonce,
};
}
}
protected override async includeBootstrap(): Promise<State> {
return this.getState();
}
function combineLogAndStash(
log: GitLog | undefined,
stash: GitStash | undefined,
paging = false,
): Iterable<GitCommit | GitStashCommit> {
// let commits = log?.commits;
// if (commits == null) return [];
// if (paging && log?.previousCursor != null) {
// let pagedCommits = [...commits.values()];
// const index = pagedCommits.findIndex(c => c.sha === log?.previousCursor);
// if (index !== -1) {
// pagedCommits = pagedCommits.slice(index + 1);
// } else {
// debugger;
// }
// commits = new Map(pagedCommits.map(c => [c.sha, c]));
// }
const commits = (paging ? log?.pagedCommits?.() : undefined) ?? log?.commits;
if (commits == null) return [];
if (stash?.commits == null) return [...commits.values()];
const stashCommitShaSecondParents = new Set(
filterMap(stash.commits.values(), c => (c.parents.length > 1 ? c.parents[1] : undefined)),
);
const filteredCommits = filter(
commits.values(),
c => !stash.commits.has(c.sha) && !stashCommitShaSecondParents.has(c.sha),
);
const filteredStashCommits = filter(stash.commits.values(), c => !c.parents?.length || commits.has(c.parents[0]));
return union(filteredCommits, filteredStashCommits);
}
function isDarkTheme(theme: ColorTheme): boolean {
return theme.kind === ColorThemeKind.Dark || theme.kind === ColorThemeKind.HighContrast;
async function convertToRows(
commits: Iterable<GitCommit>,
branches: GitBranch[],
tags: GitTag[],
remotes: GitRemote[],
getRemoteIconUrl: (icon?: string) => string | undefined,
): Promise<GraphRow[]> {
const rows: GraphRow[] = [];
let graphHeads: Head[];
let graphTags: Tag[];
let graphRemotes: Remote[];
let parents: string[];
let stash: boolean;
const remoteMap = new Map(remotes.map(r => [r.name, r]));
for (const commit of commits) {
graphHeads = [
...filterMap(branches, b => {
if (b.sha !== commit.sha || b.remote) return undefined;
return {
name: b.name,
isCurrentHead: b.current,
};
}),
];
graphRemotes = [
...filterMap(branches, b => {
if (b.sha !== commit.sha || !b.remote) return undefined;
const remoteName = b.getRemoteName();
const remote = remoteName != null ? remoteMap.get(remoteName) : undefined;
return {
name: b.getNameWithoutRemote(),
url: remote?.url,
avatarUrl:
remote?.provider?.avatarUri?.toString(true) ??
(remote?.provider?.icon != null ? getRemoteIconUrl(remote.provider.icon) : undefined),
owner: remote?.name,
};
}),
];
graphTags = [
...filterMap(tags, t => {
if (t.sha !== commit.sha) return undefined;
return {
name: t.name,
annotated: Boolean(t.message),
};
}),
];
stash = isStash(commit);
parents = commit.parents;
// Remove the second parent, if existing, from each stash commit as it affects column processing
if (parents.length > 1 && stash) {
parents = [...parents].splice(1, 1);
}
rows.push({
sha: commit.sha,
parents: parents,
author: commit.author.name,
avatarUrl: !stash ? (await commit.getAvatarUri())?.toString(true) : undefined,
email: commit.author.email ?? '',
date: commit.committer.date.getTime(),
message: emojify(commit.message && String(commit.message).length ? commit.message : commit.summary),
type: getCommitType(commit), // TODO: review logic for stash, wip, etc
heads: graphHeads,
remotes: graphRemotes,
tags: graphTags,
});
}
return rows;
}
function isLightTheme(theme: ColorTheme): boolean {
return theme.kind === ColorThemeKind.Light || theme.kind === ColorThemeKind.HighContrastLight;
function formatLog(log: GitLog | undefined): GraphLog | undefined {
if (log == null) return undefined;
return {
count: log.count,
limit: log.limit,
hasMore: log.hasMore,
cursor: log.cursor,
};
}
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 formatRepositories(repositories: Repository[]): GraphRepository[] {
if (repositories.length === 0) return repositories;
return repositories.map(r => ({
formattedName: r.formattedName,
id: r.id,
name: r.name,
path: r.path,
}));
}
@ -499,75 +612,10 @@ function getCommitType(commit: GitCommit | GitStashCommit): CommitType {
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 formatRemotes(
remotes: GitRemote[] | undefined,
getIconUrl: (icon?: string) => string | undefined,
): GraphRemote[] | undefined {
return remotes?.map(r => ({
name: r.name,
url: r.url,
avatarUrl:
r.provider?.avatarUri?.toString(true) ??
(r.provider?.icon != null ? getIconUrl(r.provider.icon) : undefined),
}));
}
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 isDarkTheme(theme: ColorTheme): boolean {
return theme.kind === ColorThemeKind.Dark || theme.kind === ColorThemeKind.HighContrast;
}
function formatLog(log: GitLog) {
return {
count: log.count,
limit: log.limit,
hasMore: log.hasMore,
cursor: log.cursor,
};
function isLightTheme(theme: ColorTheme): boolean {
return theme.kind === ColorThemeKind.Light || theme.kind === ColorThemeKind.HighContrastLight;
}

+ 28
- 10
src/plus/webviews/graph/protocol.ts View File

@ -1,15 +1,12 @@
import type { Remote } from '@gitkraken/gitkraken-components';
import type { CommitType, GraphRow, Remote } from '@gitkraken/gitkraken-components';
import type { GraphColumnConfig, GraphConfig } from '../../../config';
import { IpcCommandType, IpcNotificationType } from '../../../webviews/protocol';
export interface State {
repositories?: GraphRepository[];
selectedRepository?: string;
commits?: GraphCommit[];
rows?: GraphRow[];
config?: GraphCompositeConfig;
remotes?: GraphRemote[];
tags?: GraphTag[];
branches?: GraphBranch[];
log?: GraphLog;
nonce?: string;
mixedColumnColors?: Record<string, string>;
@ -23,8 +20,28 @@ export interface GraphLog {
cursor?: string;
}
export type GraphRepository = Record<string, any>;
export type GraphCommit = Record<string, any>;
export interface GraphRepository {
formattedName: string;
id: string;
name: string;
path: string;
}
export interface GraphCommitIdentity {
name: string;
email: string | undefined;
date: number;
}
export interface GraphCommit {
sha: string;
author: GraphCommitIdentity;
message: string;
parents: string[];
committer: GraphCommitIdentity;
type: CommitType;
avatarUrl: string | undefined;
}
export type GraphRemote = Remote;
export type GraphTag = Record<string, any>;
export type GraphBranch = Record<string, any>;
@ -55,11 +72,11 @@ export interface UpdateSelectedRepositoryParams {
path: string;
}
export const UpdateSelectedRepositoryCommandType = new IpcCommandType<UpdateSelectedRepositoryParams>(
'graph/update/selectedRepository',
'graph/update/repositorySelection',
);
export interface UpdateSelectionParams {
selection: GraphCommit[];
selection: string[];
}
export const UpdateSelectionCommandType = new IpcCommandType<UpdateSelectionParams>('graph/update/selection');
@ -77,7 +94,8 @@ export const DidChangeGraphConfigurationNotificationType = new IpcNotificationTy
);
export interface DidChangeCommitsParams {
commits: GraphCommit[];
rows: GraphRow[];
previousCursor?: string;
log?: GraphLog;
}
export const DidChangeCommitsNotificationType = new IpcNotificationType<DidChangeCommitsParams>(

+ 6
- 81
src/webviews/apps/plus/graph/GraphWrapper.tsx View File

@ -4,21 +4,14 @@ import GraphContainer, {
type GraphColumnsSettings as GKGraphColumnsSettings,
type GraphRow,
type GraphZoneType,
type Head,
type Remote,
type Tag,
} from '@gitkraken/gitkraken-components';
import type { ReactElement } from 'react';
import React, { createElement, useEffect, useRef, useState } from 'react';
import type { GraphColumnConfig } from '../../../../config';
import type {
CommitListCallback,
GraphBranch,
GraphCommit,
GraphCompositeConfig,
GraphRemote,
GraphRepository,
GraphTag,
State,
} from '../../../../plus/webviews/graph/protocol';
@ -29,7 +22,7 @@ export interface GraphWrapperProps extends State {
onColumnChange?: (name: string, settings: GraphColumnConfig) => void;
onMoreCommits?: (limit?: number) => void;
onDismissPreview?: () => void;
onSelectionChange?: (selection: GraphCommit[]) => void;
onSelectionChange?: (selection: string[]) => void;
}
// Copied from original pushed code of Miggy E.
@ -64,71 +57,6 @@ const getStyleProps = (
};
};
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),
);
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: matchingRemote?.url,
avatarUrl: matchingRemote?.avatarUrl ?? undefined,
...(matchingRemote?.name !== undefined ? { owner: matchingRemote.name } : {}),
};
});
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 },
@ -192,11 +120,8 @@ const getIconElementLibrary = (iconKey: string) => {
// eslint-disable-next-line @typescript-eslint/naming-convention
export function GraphWrapper({
subscriber,
commits = [],
repositories = [],
remotes = [],
tags = [],
branches = [],
rows = [],
selectedRepository,
config,
log,
@ -209,7 +134,7 @@ export function GraphWrapper({
previewBanner = true,
onDismissPreview,
}: GraphWrapperProps) {
const [graphList, setGraphList] = useState(getGraphModel(commits, remotes, tags, branches));
const [graphList, setGraphList] = useState(rows);
const [reposList, setReposList] = useState(repositories);
const [currentRepository, setCurrentRepository] = useState<GraphRepository | undefined>(
reposList.find(item => item.path === selectedRepository),
@ -250,7 +175,7 @@ export function GraphWrapper({
}, [mainRef]);
function transformData(state: State) {
setGraphList(getGraphModel(state.commits, state.remotes, state.tags, state.branches));
setGraphList(state.rows ?? []);
setReposList(state.repositories ?? []);
setCurrentRepository(reposList.find(item => item.path === state.selectedRepository));
setGraphColSettings(getGraphColSettingsModel(state.config));
@ -288,7 +213,7 @@ export function GraphWrapper({
};
const handleSelectGraphRows = (graphRows: GraphRow[]) => {
onSelectionChange?.(graphRows);
onSelectionChange?.(graphRows.map(r => r.sha));
};
const handleDismissBanner = () => {
@ -301,7 +226,7 @@ export function GraphWrapper({
{showBanner && (
<section className="graph-app__banner">
<div className="alert">
<span className="alert__icon codicon codicon-eye"></span>
<span className="alert__icon codicon codicon-search"></span>
<div className="alert__content">
<p className="alert__title">Preview Feature</p>
<p className="alert__message">

+ 55
- 14
src/webviews/apps/plus/graph/graph.tsx View File

@ -3,7 +3,7 @@ import type { CssVariables } from '@gitkraken/gitkraken-components';
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import type { GraphColumnConfig } from '../../../../config';
import type { CommitListCallback, GraphCommit, GraphRepository, State } from '../../../../plus/webviews/graph/protocol';
import type { CommitListCallback, GraphRepository, State } from '../../../../plus/webviews/graph/protocol';
import {
DidChangeCommitsNotificationType,
DidChangeGraphConfigurationNotificationType,
@ -11,7 +11,7 @@ import {
DismissPreviewCommandType,
GetMoreCommitsCommandType,
UpdateColumnCommandType,
UpdateSelectedRepositoryCommandType,
UpdateSelectedRepositoryCommandType as UpdateRepositorySelectionCommandType,
UpdateSelectionCommandType,
} from '../../../../plus/webviews/graph/protocol';
import { debounce } from '../../../../system/function';
@ -36,7 +36,6 @@ const graphLaneThemeColors = new Map([
export class GraphApp extends App<State> {
private callback?: CommitListCallback;
private $menu?: HTMLElement;
constructor() {
super('GraphApp');
@ -56,9 +55,12 @@ export class GraphApp extends App {
(name: string, settings: GraphColumnConfig) => this.onColumnChanged(name, settings),
250,
)}
onSelectRepository={debounce((path: GraphRepository) => this.onRepositoryChanged(path), 250)}
onMoreCommits={(...params) => this.onMoreCommits(...params)}
onSelectionChange={debounce((selection: GraphCommit[]) => this.onSelectionChanged(selection), 250)}
onSelectRepository={debounce(
(path: GraphRepository) => this.onRepositorySelectionChanged(path),
250,
)}
onMoreCommits={(...params) => this.onGetMoreCommits(...params)}
onSelectionChange={debounce((selection: string[]) => this.onSelectionChanged(selection), 250)}
onDismissPreview={() => this.onDismissPreview()}
{...this.state}
/>,
@ -90,9 +92,50 @@ export class GraphApp extends App {
this.log(`${this.appName}.onMessageReceived(${msg.id}): name=${msg.method}`);
onIpc(DidChangeCommitsNotificationType, msg, params => {
let rows;
if (params?.previousCursor != null && this.state.rows != null) {
const previousRows = this.state.rows;
const lastSha = previousRows[previousRows.length - 1]?.sha;
let previousRowsLength = previousRows.length;
const newRowsLength = params.rows.length;
rows = [];
// Preallocate the array to avoid reallocations
rows.length = previousRowsLength + newRowsLength;
if (params.previousCursor !== lastSha) {
let i = 0;
let row;
for (row of previousRows) {
rows[i++] = row;
if (row.sha === params.previousCursor) {
previousRowsLength = i;
if (previousRowsLength !== previousRows.length) {
// If we stopped before the end of the array, we need to trim it
rows.length = previousRowsLength + newRowsLength;
}
break;
}
}
} else {
for (let i = 0; i < previousRowsLength; i++) {
rows[i] = previousRows[i];
}
}
for (let i = 0; i < newRowsLength; i++) {
rows[previousRowsLength + i] = params.rows[i];
}
} else {
rows = params.rows;
}
this.setState({
...this.state,
commits: params.commits,
rows: rows,
log: params.log,
});
this.refresh(this.state);
@ -167,19 +210,19 @@ export class GraphApp extends App {
});
}
private onRepositoryChanged(repo: GraphRepository) {
this.sendCommand(UpdateSelectedRepositoryCommandType, {
private onRepositorySelectionChanged(repo: GraphRepository) {
this.sendCommand(UpdateRepositorySelectionCommandType, {
path: repo.path,
});
}
private onMoreCommits(limit?: number) {
private onGetMoreCommits(limit?: number) {
this.sendCommand(GetMoreCommitsCommandType, {
limit: limit,
});
}
private onSelectionChanged(selection: GraphCommit[]) {
private onSelectionChanged(selection: string[]) {
this.sendCommand(UpdateSelectionCommandType, {
selection: selection,
});
@ -194,9 +237,7 @@ export class GraphApp extends App {
}
private refresh(state: State) {
if (this.callback !== undefined) {
this.callback(state);
}
this.callback?.(state);
}
}

Loading…
Cancel
Save