diff --git a/README.md b/README.md index 17fffda..3ca363d 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,15 @@ This extension depends on [Code for IBM i](https://github.com/halcyon-tech/code- This adds 'Status', 'Commits' and 'File History' to the source control view. It will assume that your home directory, set in Code for IBM i is also a git repository. When you change your home directory, the panels will refresh automatically. -#### Status +TODO: Stashing + +#### Branches -This view will allow you to stage, unstage, restore and view a diff of your working tree. +This view will display remote and local branches and allow you to create, delete, checkout, and merge branches. -To do: +#### Status -* Commit, pull & push +This view will allow you to commit, pull, push, stage, unstage, restore and view a diff of your working tree. #### Commits diff --git a/extension.js b/extension.js index 921eb11..c9ad6ce 100644 --- a/extension.js +++ b/extension.js @@ -4,6 +4,7 @@ const vscode = require(`vscode`); const {instance, Field, CustomUI} = vscode.extensions.getExtension(`halcyontechltd.code-for-ibmi`).exports; +const branchesView = require(`./src/views/branches`); const statusView = require(`./src/views/status`); const commitView = require(`./src/views/commits`); const fileHistory = require(`./src/views/fileHistory`); @@ -19,6 +20,11 @@ function activate(context) { console.log(`Congratulations, your extension "git-client-ibmi" is now active!`); context.subscriptions.push( + vscode.window.registerTreeDataProvider( + `git-client-ibmi.branches`, + new branchesView(context) + ), + vscode.window.registerTreeDataProvider( `git-client-ibmi.status`, new statusView(context) diff --git a/package.json b/package.json index e04dc93..7d78af3 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,12 @@ "onCommand:git-client-ibmi.commits.refresh", "onView:git-client-ibmi.fileHistory", "onCommand:git-client-ibmi.viewCommitFile", - "onCommand:git-client-ibmi.viewCommitFileDiff" + "onCommand:git-client-ibmi.viewCommitFileDiff", + "onCommand:git-client-ibmi.branches.refresh", + "onCommand:git-client-ibmi.branches.branch", + "onCommand:git-client-ibmi.branches.deleteBranch", + "onCommand:git-client-ibmi.branches.checkout", + "onCommand:git-client-ibmi.branches.merge" ], "main": "./extension", "contributes": { @@ -104,6 +109,33 @@ "command": "git-client-ibmi.viewCommitFileDiff", "title": "View diff", "category": "Git on IBM i" + }, + { + "command": "git-client-ibmi.branches.branch", + "title": "Create Branch", + "category": "Git on IBM i", + "icon": "$(git-branch-create)" + }, + { + "command": "git-client-ibmi.branches.deleteBranch", + "title": "Delete Branch", + "category": "Git on IBM i" + }, + { + "command": "git-client-ibmi.branches.checkout", + "title": "Checkout Branch", + "category": "Git on IBM i" + }, + { + "command": "git-client-ibmi.branches.merge", + "title": "Merge Branch", + "category": "Git on IBM i" + }, + { + "command": "git-client-ibmi.branches.refresh", + "title": "Refresh branches view", + "category": "Git on IBM i", + "icon": "$(refresh)" } ], "viewsWelcome": [{ @@ -113,6 +145,11 @@ }], "views": { "scm": [{ + "id": "git-client-ibmi.branches", + "name": "Branches", + "contextualTitle": "IBM i" + }, + { "id": "git-client-ibmi.status", "name": "Status", "contextualTitle": "IBM i" @@ -156,6 +193,16 @@ "command": "git-client-ibmi.commits.refresh", "group": "navigation", "when": "view == git-client-ibmi.commits" + }, + { + "command": "git-client-ibmi.branches.branch", + "group": "navigation", + "when": "view == git-client-ibmi.branches" + }, + { + "command": "git-client-ibmi.branches.refresh", + "group": "navigation", + "when": "view == git-client-ibmi.branches" } ], "view/item/context": [ @@ -174,6 +221,18 @@ { "command": "git-client-ibmi.viewCommitFile", "when": "view == git-client-ibmi.commits && viewItem == commitFile" + }, + { + "command": "git-client-ibmi.branches.checkout", + "when": "view == git-client-ibmi.branches && viewItem == remote || viewItem == local" + }, + { + "command": "git-client-ibmi.branches.deleteBranch", + "when": "view == git-client-ibmi.branches && viewItem == remote || viewItem == local" + }, + { + "command": "git-client-ibmi.branches.merge", + "when": "view == git-client-ibmi.branches && viewItem == local" } ] } @@ -195,4 +254,4 @@ "mocha": "^8.2.1", "vscode-test": "^1.5.0" } -} \ No newline at end of file +} diff --git a/src/api/git.js b/src/api/git.js index f3f6580..111056c 100644 --- a/src/api/git.js +++ b/src/api/git.js @@ -219,4 +219,108 @@ module.exports = class Git { this.path, ); } -} \ No newline at end of file + + /** + * @returns {remote: branch_name[], local: {branch_name, state}[]}} + */ + async list_branches() { + const connection = instance.getConnection(); + let remote = [], local = []; + + let item = {branch_name: '', state: ''}; + let content = await connection.paseCommand( + `echo '"' && ${this.gitPath} branch --all --list`, + this.path, + ); + + content = content.substring(1); + + for (let line of content.split(`\n`)) { + if (line.trim() === ``) continue; + + item.state = (line[0] == '*') ? 'checked out' : ''; + item.branch_name = line.substr(2); + const remote_or_local = (item.branch_name.split('/')[0] == 'remotes') ? 'remote' : 'local'; + + switch (remote_or_local) { + case `remote`: + remote.push(item.branch_name); + break; + case `local`: + local.push({branch_name: item.branch_name, state: item.state}); + break; + } + } + + return {remote, local}; + } + + /** + * Create a branch + * @param {string} new_branch_name + */ + async create_branch(new_branch_name) { + const connection = instance.getConnection(); + await connection.paseCommand( + `${this.gitPath} branch "${new_branch_name}"`, + this.path, + ); + } + + /** + * Delete a remote branch + * @param {string} branch_to_delete + * @param {string} remote_or_local + */ + async deleteBranch(branch_to_delete, remote_or_local) { + let result = await vscode.window.showWarningMessage(`Are you sure you want to delete branch ${branch_to_delete}?`, `Yes`, `Cancel`); + + if (result === `Yes`) { + const connection = instance.getConnection(); + if(remote_or_local == "remote"){ + const split_branch_to_delete = branch_to_delete.split('/'); + var command = `${this.gitPath} push "${split_branch_to_delete[1]}" --delete "${split_branch_to_delete[2]}"`; + } + else{ + var command = `${this.gitPath} branch -D "${branch_to_delete}"`; + } + + await connection.paseCommand( + command, + this.path, + ); + } + } + + /** + * Checkout a branch + * @param {string} branch_to_checkout + * @param {string} remote_or_local + */ + async checkout(branch_to_checkout, remote_or_local) { + const connection = instance.getConnection(); + if(remote_or_local == "remote"){ + const split_branch_name = branch_to_checkout.split('/'); + var command = `${this.gitPath} checkout -b "${split_branch_name[2]}" "${split_branch_name[1]}"/"${split_branch_name[2]}"`; + } + else{ + var command = `${this.gitPath} checkout "${branch_to_checkout}"`; + } + await connection.paseCommand( + command, + this.path, + ); + } + + /** + * Merge a branch into the current branch + * @param {string} branch_to_merge_into_current_branch + */ + async merge(branch_to_merge_into_current_branch) { + const connection = instance.getConnection(); + await connection.paseCommand( + `${this.gitPath} merge "${branch_to_merge_into_current_branch}"`, + this.path, + ); + } +} diff --git a/src/views/branches.js b/src/views/branches.js new file mode 100644 index 0000000..c9aea63 --- /dev/null +++ b/src/views/branches.js @@ -0,0 +1,242 @@ + +const vscode = require(`vscode`); +const {instance} = vscode.extensions.getExtension(`halcyontechltd.code-for-ibmi`).exports; +const Git = require(`../api/git`); + +module.exports = class Branches { + /** + * @param {vscode.ExtensionContext} context + */ + constructor(context) { + this.branch_list = undefined; + + this.emitter = new vscode.EventEmitter(); + this.onDidChangeTreeData = this.emitter.event; + + context.subscriptions.push( + vscode.commands.registerCommand(`git-client-ibmi.branches.refresh`, async () => { + this.refresh(); + }), + + vscode.commands.registerCommand(`git-client-ibmi.branches.branch`, async () => { + const connection = instance.getConnection(); + const repoPath = connection.config.homeDirectory; + const repo = new Git(repoPath); + + if (connection) { + if (repo.canUseGit() && await repo.isGitRepo()) { + const new_branch_name = await vscode.window.showInputBox({ + prompt: `New branch name` + }); + + if (new_branch_name) { + try { + await repo.create_branch(new_branch_name); + vscode.window.showInformationMessage(`Branch created successfully.`); + } catch (e) { + vscode.window.showErrorMessage(`Error creating branch in ${repoPath}. ${e}`); + } + + this.refresh(); + } + } + } + }), + + vscode.commands.registerCommand(`git-client-ibmi.branches.deleteBranch`, async (node) => { + const connection = instance.getConnection(); + const repoPath = connection.config.homeDirectory; + const repo = new Git(repoPath); + + if (connection) { + if (repo.canUseGit() && await repo.isGitRepo()) { + if (!node){ + var branch_to_delete = await vscode.window.showInputBox({ + prompt: `Branch to delete` + }); + var remote_or_local = null; + } + else{ + var branch_to_delete = node.branch_name; + var remote_or_local = node.contextValue; + } + + if (branch_to_delete) { + try { + await repo.deleteBranch(branch_to_delete, remote_or_local); + vscode.window.showInformationMessage(`Branch successfully deleted.`); + } catch (e) { + vscode.window.showErrorMessage(`Error deleting branch in ${repoPath}. ${e}`); + } + + this.refresh(); + } + } + } + }), + + //TODO: refresh content of files already open when checking out different branch. how should we handle unsaved changes? + vscode.commands.registerCommand(`git-client-ibmi.branches.checkout`, async (node) => { + const connection = instance.getConnection(); + const repoPath = connection.config.homeDirectory; + const repo = new Git(repoPath); + + if (connection) { + if (repo.canUseGit() && await repo.isGitRepo()) { + if (!node){ + var branch_to_checkout = await vscode.window.showInputBox({ + prompt: `Name of branch to checkout` + }); + var remote_or_local = null; + } + else{ + var branch_to_checkout = node.branch_name; + var remote_or_local = node.contextValue; + } + + if (branch_to_checkout) { + try { + await repo.checkout(branch_to_checkout, remote_or_local); + await vscode.commands.executeCommand(`git-client-ibmi.commits.refresh`); + vscode.window.showInformationMessage(`${branch_to_checkout} checked out successfully.`); + } catch (e) { + vscode.window.showErrorMessage(`Error checking out branch in ${repoPath}. ${e}`); + } + + this.refresh(); + } + } + } + }), + + vscode.commands.registerCommand(`git-client-ibmi.branches.merge`, async (node) => { + const connection = instance.getConnection(); + const repoPath = connection.config.homeDirectory; + const repo = new Git(repoPath); + + if (connection) { + if (repo.canUseGit() && await repo.isGitRepo()) { + if (!node){ + var branch_to_merge_into_current_branch = await vscode.window.showInputBox({ + prompt: `Name of branch to merge into the current branch` + }); + } + else{ + var branch_to_merge_into_current_branch = node.branch_name; + } + + if (branch_to_merge_into_current_branch) { + try { + await repo.merge(branch_to_merge_into_current_branch); + await vscode.commands.executeCommand(`git-client-ibmi.commits.refresh`); + vscode.window.showInformationMessage(`${branch_to_merge_into_current_branch} successfully merged into current branch.`); + } catch (e) { + vscode.window.showErrorMessage(`Error merging branch in ${repoPath}. ${e}`); + } + + this.refresh(); + } + } + } + }), + + vscode.workspace.onDidChangeConfiguration(async event => { + if (event.affectsConfiguration(`code-for-ibmi.connectionSettings`)) { + this.refresh(); + } + }), + ); + } + + refresh() { + this.emitter.fire(); + } + + /** + * + * @param {vscode.TreeItem} element + * @returns {vscode.TreeItem} + */ + getTreeItem(element) { + return element; + } + + /** + * @param {Branch} [element] + * @returns {Promise} + */ + async getChildren(element) { + const connection = instance.getConnection(); + + /** @type {vscode.TreeItem[]} */ + let items = []; + + if (connection) { + const repoPath = connection.config.homeDirectory; + const repo = new Git(repoPath); + + if (repo.canUseGit() && await repo.isGitRepo()) { + + if (element) { + switch (element.contextValue) { + case `remote_branches`: + items = this.branch_list.remote.map(item => new Branch(item, 'remote')); + items.contextValue = 'remotes'; + break; + case `local_branches`: + items = this.branch_list.local.map(item => new Branch(item.branch_name, 'local', item.state)); + break; + } + } else { + items = [ + new Subitem('Remote Branches', 'remote_branches'), + new Subitem('Local Branches', 'local_branches') + ] + } + + try { + const branch_list = await repo.list_branches(); + this.branch_list = branch_list; + } catch (e) { + items = [new vscode.TreeItem(`Error fetching branches for ${repoPath}`)]; + } + + } else { + items = [new vscode.TreeItem(`${repoPath} is not a git repository.`)]; + } + + } else { + items = [new vscode.TreeItem(`Please connect to an IBM i and refresh.`)]; + } + + return items; + } +}; + +class Subitem extends vscode.TreeItem { + /** + * @param {string} label + * @param {string} contextValue + */ + constructor(label, contextValue) { + super(label, vscode.TreeItemCollapsibleState.Expanded); + this.contextValue = contextValue; + } +} + +class Branch extends vscode.TreeItem { + /** + * + * @param {string} branch_name + * @param {contextValue} contextValue + */ + constructor(branch_name, contextValue, state = "") { + super(branch_name, vscode.TreeItemCollapsibleState.None); + + this.branch_name = branch_name; + this.contextValue = contextValue; + this.description = state; //only one branch should have the description of "checked out" at a time + + this.iconPath = new vscode.ThemeIcon(`git-branch`); + } +} diff --git a/src/views/status.js b/src/views/status.js index f0704b4..121862c 100644 --- a/src/views/status.js +++ b/src/views/status.js @@ -149,6 +149,7 @@ module.exports = class Status { if (repo.canUseGit() && await repo.isGitRepo()) { try { await repo.push(); + await vscode.commands.executeCommand(`git-client-ibmi.branches.refresh`); vscode.window.showInformationMessage(`Push successful.`); } catch (e) { vscode.window.showErrorMessage(e); @@ -364,4 +365,4 @@ let descriptions = { 'U': `umerged`, '?': `untracked`, '!': `ignored` -}; \ No newline at end of file +};