diff --git a/package.json b/package.json index af7995daf4624..c9867c6038cad 100644 --- a/package.json +++ b/package.json @@ -5716,6 +5716,27 @@ "icon": "$(gitlens-switch)", "enablement": "!operationInProgress" }, + { + "command": "gitlens.views.switchToCommitStacked", + "title": "Switch to Commit (Stacked)...", + "category": "GitLens", + "icon": "$(gitlens-pop)", + "enablement": "!operationInProgress" + }, + { + "command": "gitlens.views.switchToCommitPop", + "title": "Pop Commit from Stack...", + "category": "GitLens", + "icon": "$(versions)", + "enablement": "!operationInProgress" + }, + { + "command": "gitlens.views.commitStackEmpty", + "title": "Empty Commit Stack", + "category": "GitLens", + "icon": "$()", + "enablement": "!operationInProgress" + }, { "command": "gitlens.views.switchToTag", "title": "Switch to Tag...", @@ -10614,6 +10635,11 @@ "when": "view =~ /^gitlens\\.views\\.commits/ && gitlens:plus:enabled", "group": "navigation@11" }, + { + "command": "gitlens.views.switchToCommitPop", + "when": "view =~ /^gitlens\\.views\\.commits/", + "group": "navigation@12" + }, { "command": "gitlens.views.commitDetails.refresh", "when": "view =~ /^gitlens\\.views\\.commitDetails/", @@ -11680,6 +11706,11 @@ "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b/", "group": "1_gitlens_actions@7" }, + { + "command": "gitlens.views.switchToCommitStacked", + "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b/", + "group": "1_gitlens_actions@8" + }, { "command": "gitlens.views.createBranch", "when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:commit\\b/", @@ -14823,7 +14854,7 @@ "typescript": "5.2.1-rc", "webpack": "5.88.2", "webpack-bundle-analyzer": "4.9.0", - "webpack-cli": "5.1.4", + "webpack-cli": "^5.1.4", "webpack-node-externals": "3.0.0", "webpack-require-from": "1.8.6" }, diff --git a/src/container.ts b/src/container.ts index 3a8932f84e618..c174b3fc923ce 100644 --- a/src/container.ts +++ b/src/container.ts @@ -50,6 +50,7 @@ import { GitLineTracker } from './trackers/gitLineTracker'; import { DeepLinkService } from './uris/deepLinks/deepLinkService'; import { UriService } from './uris/uriService'; import { BranchesView } from './views/branchesView'; +import { CommitStack } from './views/commitStack'; import { CommitsView } from './views/commitsView'; import { ContributorsView } from './views/contributorsView'; import { FileHistoryView } from './views/fileHistoryView'; @@ -166,6 +167,8 @@ export class Container { }, }; + readonly CommitStack = new CommitStack(this); + private _disposables: Disposable[]; private _terminalLinks: GitTerminalLinkProvider | undefined; private _webviews: WebviewsController; diff --git a/src/views/commitStack.ts b/src/views/commitStack.ts new file mode 100644 index 0000000000000..5c00f6bc51a5d --- /dev/null +++ b/src/views/commitStack.ts @@ -0,0 +1,90 @@ +import type { StatusBarItem } from 'vscode'; +import { MarkdownString, StatusBarAlignment, window } from 'vscode'; +import type { Container } from '../container'; +import type { GitBranch } from '../git/models/branch'; +import type { ViewRefNode } from './nodes/viewNode'; + +export class CommitStack { + private container: Container; + // The stack which is pushed to and popped from. + // We push and pop ViewRefNode types for convenience since these nodes + // coorespond to commit refs in the Commit view. + private stack: ViewRefNode[] = []; + // A StatusBarItem is created and displayed when the stack is not empty. + private statusBarItem?: StatusBarItem; + // The git ref that was checked out before any commit was pushed to the stack. + private originalRef?: GitBranch; + + constructor(container: Container) { + this.container = container; + } + + private renderStatusBarTooltip = (): MarkdownString => { + const tooltip = new MarkdownString(); + if (this.originalRef) { + tooltip.appendMarkdown(`**original ref**: ${this.originalRef.name}\n\n`); + } + this.stack.forEach((n: ViewRefNode, i: number) => { + tooltip.appendMarkdown(`**${i}**. **commit**: ${n.ref.name}\n\n`); + }); + return tooltip; + }; + + async push(commit: ViewRefNode): Promise { + if (this.stack.length == 0) { + // track the 'ref' the branh was on before we start adding to the + // stack, we'll restore to this ref after the stack is emptied. + this.originalRef = await this.container.git.getBranch(commit.repoPath); + this.statusBarItem = window.createStatusBarItem(StatusBarAlignment.Left, 100); + this.statusBarItem.show(); + } + this.stack.push(commit); + if (this.statusBarItem) { + this.statusBarItem.text = `commit stack: ${commit.ref.name} ${this.stack.length}`; + this.statusBarItem.tooltip = this.renderStatusBarTooltip(); + } + void window.showInformationMessage(`Pushed ${commit.ref.name} onto stack`); + return Promise.resolve(); + } + + async pop(): Promise { + if (this.stack.length == 0) { + void window.showErrorMessage( + "Stack is empty.\nUse 'Switch to Commit (Stacked) command to push a commit to the stack.", + ); + return; + } + const node = this.stack.pop(); + // this just shuts the compiler up, it doesn't understand that pop() + // won't return an undefined since we check length above. + if (!node) { + return; + } + void window.showInformationMessage(`Popped ${node.ref.name} from stack`); + if (this.stack.length == 0) { + await this.empty(); + return; + } + const curNode = this.stack[this.stack.length - 1]; + if (this.statusBarItem) { + this.statusBarItem.text = `commit stack: ${curNode.ref.name} ${this.stack.length}`; + this.statusBarItem.tooltip = this.renderStatusBarTooltip(); + } + return curNode; + } + + async empty(): Promise { + this.stack = []; + this.statusBarItem?.dispose(); + this.statusBarItem = undefined; + void window.showInformationMessage('Stack is now empty.'); + if (this.originalRef) { + // if we stored a original 'ref' before pushing to the stack, + // restore it. + await this.container.git.checkout(this.originalRef.repoPath, this.originalRef.ref); + void window.showInformationMessage(`Restored original ref to ${this.originalRef.name}`); + this.originalRef = undefined; + } + return Promise.resolve(); + } +} diff --git a/src/views/viewCommands.ts b/src/views/viewCommands.ts index 4f93e5cefc2b6..7e194e95715fd 100644 --- a/src/views/viewCommands.ts +++ b/src/views/viewCommands.ts @@ -225,6 +225,9 @@ export class ViewCommands { registerViewCommand('gitlens.views.switchToAnotherBranch', this.switch, this); registerViewCommand('gitlens.views.switchToBranch', this.switchTo, this); registerViewCommand('gitlens.views.switchToCommit', this.switchTo, this); + registerViewCommand('gitlens.views.switchToCommitStacked', this.switchToPush, this); + registerViewCommand('gitlens.views.switchToCommitPop', this.switchToPop, this); + registerViewCommand('gitlens.views.commitStackEmpty', this.commitStackEmpty, this); registerViewCommand('gitlens.views.switchToTag', this.switchTo, this); registerViewCommand('gitlens.views.addRemote', this.addRemote, this); registerViewCommand('gitlens.views.pruneRemote', this.pruneRemote, this); @@ -834,6 +837,31 @@ export class ViewCommands { return RepoActions.switchTo(getNodeRepoPath(node)); } + @debug() + private async switchToPush(node?: ViewNode) { + if (!(node instanceof ViewRefNode)) { + return; + } + await this.container.CommitStack.push(node); + return RepoActions.switchTo( + node.repoPath, + node instanceof BranchNode && node.branch.current ? undefined : node.ref, + ); + } + + @debug() + private async switchToPop() { + const nextCommit = await this.container.CommitStack.pop(); + if (nextCommit !== undefined) { + return this.switchTo(nextCommit); + } + } + + @debug() + private async commitStackEmpty() { + await this.container.CommitStack.empty(); + } + @debug() private async undoCommit(node: CommitNode | FileRevisionAsCommitNode) { if (!(node instanceof CommitNode) && !(node instanceof FileRevisionAsCommitNode)) return;