From 9bb1012c40237035759c14e73892310fc81c3c18 Mon Sep 17 00:00:00 2001 From: Eric Amodio Date: Mon, 26 Sep 2022 01:29:28 -0400 Subject: [PATCH] Adds context menus to the Graph (wip) --- package.json | 133 +++++++++++++++++++ src/env/node/git/localGitProvider.ts | 108 +++++++++++++-- src/git/models/graph.ts | 4 +- src/plus/webviews/graph/graphWebview.ts | 167 +++++++++++++++++++++++- src/system/webview.ts | 15 +++ src/webviews/apps/plus/graph/graph.html | 2 +- 6 files changed, 411 insertions(+), 18 deletions(-) create mode 100644 src/system/webview.ts diff --git a/package.json b/package.json index 065a674dca8f5..b1a31f9914f90 100644 --- a/package.json +++ b/package.json @@ -6249,6 +6249,56 @@ "command": "gitlens.disableDebugLogging", "title": "Disable Debug Logging", "category": "GitLens" + }, + { + "command": "gitlens.graph.switchToAnotherBranch", + "title": "Switch to Another Branch...", + "category": "GitLens", + "icon": "$(gitlens-switch)" + }, + { + "command": "gitlens.graph.switchToBranch", + "title": "Switch to Branch...", + "category": "GitLens", + "icon": "$(gitlens-switch)" + }, + { + "command": "gitlens.graph.switchToCommit", + "title": "Switch to Commit...", + "category": "GitLens", + "icon": "$(gitlens-switch)" + }, + { + "command": "gitlens.graph.switchToTag", + "title": "Switch to Tag...", + "category": "GitLens", + "icon": "$(gitlens-switch)" + }, + { + "command": "gitlens.graph.rebaseOntoCommit", + "title": "Rebase Current Branch onto Commit...", + "category": "GitLens" + }, + { + "command": "gitlens.graph.resetCommit", + "title": "Reset Current Branch to Previous Commit...", + "category": "GitLens" + }, + { + "command": "gitlens.graph.resetToCommit", + "title": "Reset Current Branch to Commit...", + "category": "GitLens" + }, + { + "command": "gitlens.graph.revert", + "title": "Revert Commit...", + "category": "GitLens" + }, + { + "command": "gitlens.graph.undoCommit", + "title": "Undo Commit", + "category": "GitLens", + "icon": "$(discard)" } ], "icons": { @@ -8036,6 +8086,42 @@ "command": "gitlens.views.worktrees.setShowBranchPullRequestOff", "when": "false" }, + { + "command": "gitlens.graph.switchToAnotherBranch", + "when": "false" + }, + { + "command": "gitlens.graph.switchToBranch", + "when": "false" + }, + { + "command": "gitlens.graph.switchToCommit", + "when": "false" + }, + { + "command": "gitlens.graph.switchToTag", + "when": "false" + }, + { + "command": "gitlens.graph.rebaseOntoCommit", + "when": "false" + }, + { + "command": "gitlens.graph.resetCommit", + "when": "false" + }, + { + "command": "gitlens.graph.resetToCommit", + "when": "false" + }, + { + "command": "gitlens.graph.revert", + "when": "false" + }, + { + "command": "gitlens.graph.undoCommit", + "when": "false" + }, { "command": "gitlens.enableDebugLogging", "when": "config.gitlens.outputLevel != debug" @@ -10467,6 +10553,53 @@ "group": "1_gitlens@0" } ], + "webview/context": [ + { + "command": "gitlens.graph.switchToAnotherBranch", + "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?=.*?\\b\\+current\\b)/", + "group": "1_gitlens_actions@1" + }, + { + "command": "gitlens.graph.switchToBranch", + "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:branch\\b(?!.*?\\b\\+current\\b)/", + "group": "1_gitlens_actions@1" + }, + { + "command": "gitlens.graph.undoCommit", + "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b(?=.*?\\b\\+HEAD\\b)/", + "group": "1_gitlens_actions@1" + }, + { + "command": "gitlens.graph.revert", + "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b(?=.*?\\b\\+current\\b)/", + "group": "1_gitlens_actions@3" + }, + { + "command": "gitlens.graph.resetToCommit", + "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b(?=.*?\\b\\+current\\b)/", + "group": "1_gitlens_actions@4" + }, + { + "command": "gitlens.graph.resetCommit", + "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b(?=.*?\\b\\+current\\b)/", + "group": "1_gitlens_actions@5" + }, + { + "command": "gitlens.graph.rebaseOntoCommit", + "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b/", + "group": "1_gitlens_actions@6" + }, + { + "command": "gitlens.graph.switchToCommit", + "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:commit\\b/", + "group": "1_gitlens_actions@7" + }, + { + "command": "gitlens.graph.switchToTag", + "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && webviewItem =~ /gitlens:tag\\b/", + "group": "1_gitlens_actions@1" + } + ], "gitlens/commit/browse": [ { "command": "gitlens.views.browseRepoAtRevision", diff --git a/src/env/node/git/localGitProvider.ts b/src/env/node/git/localGitProvider.ts index 08c739b1e0500..e6f5b14ee7f01 100644 --- a/src/env/node/git/localGitProvider.ts +++ b/src/env/node/git/localGitProvider.ts @@ -60,6 +60,7 @@ import { GitFileChange } from '../../../git/models/file'; import type { GitGraph, GitGraphRow, + GitGraphRowContexts, GitGraphRowHead, GitGraphRowRemoteHead, GitGraphRowTag, @@ -118,6 +119,7 @@ import { showGitMissingErrorMessage, showGitVersionUnsupportedErrorMessage, } from '../../../messages'; +import type { GraphItemContext, GraphItemRefContext } from '../../../plus/webviews/graph/graphWebview'; import { countStringLength, filterMap } from '../../../system/array'; import { TimedCancellationSource } from '../../../system/cancellation'; import { gate } from '../../../system/decorators/gate'; @@ -140,6 +142,7 @@ import { any, fastestSettled, getSettledValue } from '../../../system/promise'; import { equalsIgnoreCase, getDurationMilliseconds, interpolate, md5, splitSingle } from '../../../system/string'; import { PathTrie } from '../../../system/trie'; import { compare, fromString } from '../../../system/version'; +import { serializeWebviewItemContext } from '../../../system/webview'; import type { CachedBlame, CachedDiff, CachedLog, TrackedDocument } from '../../../trackers/gitDocumentTracker'; import { GitDocumentState } from '../../../trackers/gitDocumentTracker'; import type { Git } from './git'; @@ -1731,12 +1734,15 @@ export class LocalGitProvider implements GitProvider, Disposable { const rows: GitGraphRow[] = []; let current = false; + let headCommit = false; let refHeads: GitGraphRowHead[]; let refRemoteHeads: GitGraphRowRemoteHead[]; let refTags: GitGraphRowTag[]; let parents: string[]; let remoteName: string; - let isStashCommit: boolean; + let stashCommit: GitStashCommit | undefined; + let tag: GitGraphRowTag; + let contexts: GitGraphRowContexts | undefined; let count = 0; @@ -1753,24 +1759,40 @@ export class LocalGitProvider implements GitProvider, Disposable { refHeads = []; refRemoteHeads = []; refTags = []; + contexts = undefined; + headCommit = false; if (commit.tips) { for (let tip of commit.tips.split(', ')) { if (tip === 'refs/stash') continue; if (tip.startsWith('tag: ')) { - refTags.push({ + tag = { name: tip.substring(5), // Not currently used, so don't bother looking it up annotated: true, + }; + tag.context = serializeWebviewItemContext({ + webviewItem: 'gitlens:tag', + webviewItemValue: { + type: 'tag', + ref: GitReference.create(tag.name, repoPath, { + refType: 'tag', + name: tag.name, + }), + }, }); + refTags.push(tag); continue; } current = tip.startsWith('HEAD'); - if (current && tip !== 'HEAD') { - tip = tip.substring(8); + if (current) { + headCommit = true; + if (tip !== 'HEAD') { + tip = tip.substring(8); + } } remoteName = getRemoteNameFromBranchName(tip); @@ -1788,6 +1810,18 @@ export class LocalGitProvider implements GitProvider, Disposable { remote.provider?.avatarUri ?? getRemoteIconUri(this.container, remote, asWebviewUri) )?.toString(true), + context: serializeWebviewItemContext({ + webviewItem: 'gitlens:branch+remote', + webviewItemValue: { + type: 'branch', + ref: GitReference.create(branchName, repoPath, { + refType: 'branch', + name: branchName, + remote: true, + upstream: remote.name, + }), + }, + }), }); continue; @@ -1797,15 +1831,61 @@ export class LocalGitProvider implements GitProvider, Disposable { refHeads.push({ name: tip, isCurrentHead: current, + // TODO@eamodio Add +tracking + context: serializeWebviewItemContext({ + webviewItem: `gitlens:branch${current ? '+current' : ''}`, + webviewItemValue: { + type: 'branch', + ref: GitReference.create(tip, repoPath, { + refType: 'branch', + name: tip, + remote: false, + // upstream: undefined, + }), + }, + }), }); } } - isStashCommit = stash?.commits.has(commit.sha) ?? false; + stashCommit = stash?.commits.get(commit.sha); + + contexts = {}; + if (stashCommit != null) { + contexts.row = serializeWebviewItemContext({ + webviewItem: 'gitlens:stash', + webviewItemValue: { + type: 'stash', + ref: GitReference.create(commit.sha, repoPath, { + refType: 'stash', + name: stashCommit.name, + number: stashCommit.number, + }), + }, + }); + } else { + contexts.row = serializeWebviewItemContext({ + webviewItem: `gitlens:commit${headCommit ? '+HEAD' : ''}${true ? '+current' : ''}`, + webviewItemValue: { + type: 'commit', + ref: GitReference.create(commit.sha, repoPath, { + refType: 'revision', + message: commit.message, + }), + }, + }); + contexts.avatar = serializeWebviewItemContext({ + webviewItem: 'gitlens:avatar', + webviewItemValue: { + type: 'avatar', + email: commit.authorEmail, + }, + }); + } parents = commit.parents ? commit.parents.split(' ') : []; // Remove the second & third parent, if exists, from each stash commit as it is a Git implementation for the index and untracked files - if (isStashCommit && parents.length > 1) { + if (stashCommit != null && parents.length > 1) { // Skip the "index commit" (e.g. contains staged files) of the stash skipStashParents.add(parents[1]); // Skip the "untracked commit" (e.g. contains untracked files) of the stash @@ -1813,7 +1893,7 @@ export class LocalGitProvider implements GitProvider, Disposable { parents.splice(1, 2); } - if (!isStashCommit && !avatars.has(commit.authorEmail)) { + if (stashCommit == null && !avatars.has(commit.authorEmail)) { const uri = getCachedAvatarUri(commit.authorEmail); if (uri != null) { avatars.set(commit.authorEmail, uri.toString(true)); @@ -1824,18 +1904,20 @@ export class LocalGitProvider implements GitProvider, Disposable { sha: commit.sha, parents: parents, author: commit.author, - email: commit.authorEmail ?? '', + email: commit.authorEmail, date: Number(ordering === 'author-date' ? commit.authorDate : commit.committerDate) * 1000, message: emojify(commit.message.trim()), // TODO: review logic for stash, wip, etc - type: isStashCommit - ? GitGraphRowType.Stash - : parents.length > 1 - ? GitGraphRowType.MergeCommit - : GitGraphRowType.Commit, + type: + stashCommit != null + ? GitGraphRowType.Stash + : parents.length > 1 + ? GitGraphRowType.MergeCommit + : GitGraphRowType.Commit, heads: refHeads, remotes: refRemoteHeads, tags: refTags, + contexts: contexts, }); } diff --git a/src/git/models/graph.ts b/src/git/models/graph.ts index 6d86c15e00a3e..000f4f7d26493 100644 --- a/src/git/models/graph.ts +++ b/src/git/models/graph.ts @@ -1,8 +1,9 @@ -import type { GraphRow, Head, Remote, Tag } from '@gitkraken/gitkraken-components'; +import type { GraphRow, Head, Remote, RowContexts, Tag } from '@gitkraken/gitkraken-components'; export type GitGraphRowHead = Head; export type GitGraphRowRemoteHead = Remote; export type GitGraphRowTag = Tag; +export type GitGraphRowContexts = RowContexts; export const enum GitGraphRowType { Commit = 'commit-node', MergeCommit = 'merge-node', @@ -17,6 +18,7 @@ export interface GitGraphRow extends GraphRow { heads?: GitGraphRowHead[]; remotes?: GitGraphRowRemoteHead[]; tags?: GitGraphRowTag[]; + contexts?: GitGraphRowContexts; } export interface GitGraph { diff --git a/src/plus/webviews/graph/graphWebview.ts b/src/plus/webviews/graph/graphWebview.ts index 373a9bec8c803..4ed7b99748690 100644 --- a/src/plus/webviews/graph/graphWebview.ts +++ b/src/plus/webviews/graph/graphWebview.ts @@ -4,18 +4,25 @@ import { getAvatarUri } from '../../../avatars'; import { parseCommandContext } from '../../../commands/base'; import { GitActions } from '../../../commands/gitCommands.actions'; import { configuration } from '../../../configuration'; -import { Commands, ContextKeys } from '../../../constants'; +import { Commands, ContextKeys, CoreGitCommands } from '../../../constants'; import type { Container } from '../../../container'; import { setContext } from '../../../context'; import { PlusFeatures } from '../../../features'; import type { GitCommit } from '../../../git/models/commit'; import { GitGraphRowType } from '../../../git/models/graph'; import type { GitGraph } from '../../../git/models/graph'; +import type { + GitBranchReference, + GitRevisionReference, + GitStashReference, + GitTagReference, +} from '../../../git/models/reference'; +import { GitReference } from '../../../git/models/reference'; import type { Repository, RepositoryChangeEvent } from '../../../git/models/repository'; import { RepositoryChange, RepositoryChangeComparisonMode } from '../../../git/models/repository'; import type { GitSearch } from '../../../git/search'; import { getSearchQueryComparisonKey } from '../../../git/search'; -import { registerCommand } from '../../../system/command'; +import { executeCoreGitCommand, registerCommand } from '../../../system/command'; import { gate } from '../../../system/decorators/gate'; import { debug } from '../../../system/decorators/log'; import type { Deferrable } from '../../../system/function'; @@ -23,6 +30,8 @@ import { debounce } from '../../../system/function'; import { first, last } from '../../../system/iterable'; import { updateRecordValue } from '../../../system/object'; import { isDarkTheme, isLightTheme } from '../../../system/utils'; +import type { WebviewItemContext } from '../../../system/webview'; +import { isWebviewItemContext } from '../../../system/webview'; import { RepositoryFolderNode } from '../../../views/nodes/viewNode'; import type { IpcMessage } from '../../../webviews/protocol'; import { onIpc } from '../../../webviews/protocol'; @@ -191,7 +200,18 @@ export class GraphWebview extends WebviewBase { } protected override registerCommands(): Disposable[] { - return [registerCommand(Commands.RefreshGraphPage, () => this.refresh(true))]; + return [ + registerCommand(Commands.RefreshGraphPage, () => this.refresh(true)), + registerCommand('gitlens.graph.switchToAnotherBranch', this.switchToAnother, this), + registerCommand('gitlens.graph.switchToBranch', this.switchTo, this), + registerCommand('gitlens.graph.undoCommit', this.undoCommit, this), + registerCommand('gitlens.graph.revert', this.revertCommit, this), + registerCommand('gitlens.graph.resetToCommit', this.resetToCommit, this), + registerCommand('gitlens.graph.resetCommit', this.resetCommit, this), + registerCommand('gitlens.graph.rebaseOntoCommit', this.rebase, this), + registerCommand('gitlens.graph.switchToCommit', this.switchTo, this), + registerCommand('gitlens.graph.switchToTag', this.switchTo, this), + ]; } protected override onInitializing(): Disposable[] | undefined { @@ -794,6 +814,97 @@ export class GraphWebview extends WebviewBase { this._selectedSha = sha; this._selectedRows = sha != null ? { [sha]: true } : {}; } + + @debug() + private rebase(item: GraphItemContext) { + if (isGraphItemRefContext(item)) { + const { ref } = item.webviewItemValue; + return GitActions.rebase(ref.repoPath, ref); + } + + return Promise.resolve(); + } + + @debug() + private resetCommit(item: GraphItemContext) { + if (isGraphItemRefContext(item) && item.webviewItemValue.ref.refType === 'revision') { + const { ref } = item.webviewItemValue; + return GitActions.revert( + ref.repoPath, + GitReference.create(`${ref.ref}^`, ref.repoPath, { + refType: 'revision', + name: `${ref.name}^`, + message: ref.message, + }), + ); + } + + return Promise.resolve(); + } + + @debug() + private resetToCommit(item: GraphItemContext) { + if (isGraphItemRefContext(item) && item.webviewItemValue.ref.refType === 'revision') { + const { ref } = item.webviewItemValue; + return GitActions.reset(ref.repoPath, ref); + } + + return Promise.resolve(); + } + + @debug() + private revertCommit(item: GraphItemContext) { + if (isGraphItemRefContext(item) && item.webviewItemValue.ref.refType === 'revision') { + const { ref } = item.webviewItemValue; + return GitActions.revert(ref.repoPath, ref); + } + + return Promise.resolve(); + } + + @debug() + private switchTo(item: GraphItemContext) { + if (isGraphItemRefContext(item)) { + const { ref } = item.webviewItemValue; + return GitActions.switchTo(ref.repoPath, ref); + } + + return Promise.resolve(); + } + + @debug() + private switchToAnother(item: GraphItemContext) { + if (isGraphItemRefContext(item)) { + const { ref } = item.webviewItemValue; + return GitActions.switchTo(ref.repoPath); + } + + return Promise.resolve(); + } + + @debug() + private async undoCommit(item: GraphItemContext) { + if (isGraphItemRefContext(item) && item.webviewItemValue.ref.refType === 'revision') { + const ref = item.webviewItemValue.ref; + const repo = await this.container.git.getOrOpenScmRepository(ref.repoPath); + const commit = await repo?.getCommit('HEAD'); + + if (commit?.hash !== ref.ref) { + void window.showWarningMessage( + `Commit ${GitReference.toString(ref, { + capitalize: true, + icon: false, + })} cannot be undone, because it is no longer the most recent commit.`, + ); + + return; + } + + return void executeCoreGitCommand(CoreGitCommands.UndoCommit, ref.repoPath); + } + + return Promise.resolve(); + } } function formatRepositories(repositories: Repository[]): GraphRepository[] { @@ -806,3 +917,53 @@ function formatRepositories(repositories: Repository[]): GraphRepository[] { path: r.path, })); } + +export type GraphItemContext = WebviewItemContext; +export type GraphItemRefContext = WebviewItemContext; +export type GraphItemRefContextValue = + | GraphBranchContextValue + | GraphCommitContextValue + | GraphStashContextValue + | GraphTagContextValue; +export type GraphItemContextValue = GraphAvatarContextValue | GraphColumnsContextValue | GraphItemRefContextValue; + +export interface GraphAvatarContextValue { + type: 'avatar'; + email: string; +} + +export interface GraphColumnsContextValue { + type: 'columns'; +} + +export interface GraphBranchContextValue { + type: 'branch'; + ref: GitBranchReference; +} + +export interface GraphCommitContextValue { + type: 'commit'; + ref: GitRevisionReference; +} + +export interface GraphStashContextValue { + type: 'stash'; + ref: GitStashReference; +} + +export interface GraphTagContextValue { + type: 'tag'; + ref: GitTagReference; +} + +function isGraphItemContext(item: unknown): item is GraphItemContext { + if (item == null) return false; + + return isWebviewItemContext(item) && item.webview === 'gitlens.graph'; +} + +function isGraphItemRefContext(item: unknown): item is GraphItemRefContext { + if (item == null) return false; + + return isGraphItemContext(item) && 'ref' in item.webviewItemValue; +} diff --git a/src/system/webview.ts b/src/system/webview.ts new file mode 100644 index 0000000000000..99b402c0767fd --- /dev/null +++ b/src/system/webview.ts @@ -0,0 +1,15 @@ +export interface WebviewItemContext { + webview?: string; + webviewItem: string; + webviewItemValue: TValue; +} + +export function isWebviewItemContext(item: unknown): item is WebviewItemContext { + if (item == null) return false; + + return 'webview' in item && 'webviewItem' in item; +} + +export function serializeWebviewItemContext(context: T): string { + return JSON.stringify(context); +} diff --git a/src/webviews/apps/plus/graph/graph.html b/src/webviews/apps/plus/graph/graph.html index fb2a3f37f5bd4..fc37e06496d24 100644 --- a/src/webviews/apps/plus/graph/graph.html +++ b/src/webviews/apps/plus/graph/graph.html @@ -4,7 +4,7 @@ - +

A repository must be selected.