diff --git a/.vscodeignore b/.vscodeignore index 159277f..d0718cd 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,14 +1,14 @@ -.vscode/** .vscode-test/** +.vscode/** +.github/** +src/** +test/** out/** node_modules/** -src/** +*.vsix .gitignore -.yarnrc +tsconfig.json esbuild.js +eslint.config.mjs +vitest.config.mts vsc-extension-quickstart.md -**/tsconfig.json -**/eslint.config.mjs -**/*.map -**/*.ts -**/.vscode-test.* diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..856c49c --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,58 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship made available under the License, as indicated by a copyright notice that is included in or attached to the work. + +"Derivative Works" shall mean any work that is based on the Work, for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. + +"Contribution" shall mean any work of authorship submitted to the Licensor for inclusion in the Work. + +"Contributor" shall mean the Licensor and any Legal Entity on behalf of whom a Contribution has been received by the Licensor and included in the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable patent license to make, use, sell, offer for sale, import, and otherwise transfer the Work. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work; and + (d) If the Work includes a "NOTICE" text file, You must include a readable copy of the attribution notices contained within such NOTICE file. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution submitted for inclusion in the Work shall be under the terms and conditions of this License. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. + +8. Limitation of Liability. In no event and under no legal theory shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or exemplary damages of any character arising as a result of this License or out of the use or inability to use the Work. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations consistent with this License. However, in accepting such obligations, You may offer such obligations only on Your own behalf and on Your sole responsibility. + +END OF TERMS AND CONDITIONS + +Copyright 2025 codemanxdev + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. diff --git a/README.md b/README.md index c2d2622..61d3bc1 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,8 @@ Git Operations - Visual Git Toolkit for VS Code ## Build Status -[![CI](https://github.com/thedev-codeman/gops/actions/workflows/ci.yml/badge.svg)](https://github.com/thedev-codeman/gops/actions/workflows/ci.yml) + +[![CI Builds](https://github.com/thedev-codeman/gops/actions/workflows/ci.yml/badge.svg?branch=develop)](https://github.com/thedev-codeman/gops/actions/workflows/ci.yml) ## Features @@ -23,21 +24,15 @@ Git Operations - Visual Git Toolkit for VS Code - Local Changes - Tags - ## Requirements -If you have any requirements or dependencies, add a section describing those and how to install and configure them. +Git should be installed ## Extension Settings -Include if your extension adds any VS Code settings through the `contributes.configuration` extension point. - -For example: - This extension contributes the following settings: -- `myExtension.enable`: Enable/disable this extension. -- `myExtension.thing`: Set to `blah` to do something. +- N/A ## Known Issues @@ -47,19 +42,9 @@ Calling out known issues can help limit users opening duplicate issues against y Users appreciate release notes as you update your extension. -### 1.0.0 - -Initial release of ... +### 0.0.1 -### 1.0.1 - -Fixed issue #. - -### 1.1.0 - -Added features X, Y, and Z. - ---- +Initial release of Gops ## Following extension guidelines @@ -69,5 +54,26 @@ Ensure that you've read through the extensions guidelines and follow the best pr ## Build Process +#### Install Dependences + - npm install + +#### Compile + - npm run compile + +#### Build + +- npm run build + +#### Check the files that will be packaged + +- vsce ls + +#### Package + +- vsce package --no-dependencies + +#### Publish + +- vsce publish --no-dependencies diff --git a/esbuild.js b/esbuild.js index cc2be59..4e284a5 100644 --- a/esbuild.js +++ b/esbuild.js @@ -1,56 +1,56 @@ const esbuild = require("esbuild"); -const production = process.argv.includes('--production'); -const watch = process.argv.includes('--watch'); +const production = process.argv.includes("--production"); +const watch = process.argv.includes("--watch"); /** * @type {import('esbuild').Plugin} */ const esbuildProblemMatcherPlugin = { - name: 'esbuild-problem-matcher', + name: "esbuild-problem-matcher", - setup(build) { - build.onStart(() => { - console.log('[watch] build started'); - }); - build.onEnd((result) => { - result.errors.forEach(({ text, location }) => { - console.error(`✘ [ERROR] ${text}`); - console.error(` ${location.file}:${location.line}:${location.column}:`); - }); - console.log('[watch] build finished'); - }); - }, + setup(build) { + build.onStart(() => { + console.log("[watch] build started"); + }); + build.onEnd((result) => { + result.errors.forEach(({ text, location }) => { + console.error(`✘ [ERROR] ${text}`); + console.error( + ` ${location.file}:${location.line}:${location.column}:`, + ); + }); + console.log("[watch] build finished"); + }); + }, }; async function main() { - const ctx = await esbuild.context({ - entryPoints: [ - 'src/extension.ts' - ], - bundle: true, - format: 'cjs', - minify: production, - sourcemap: !production, - sourcesContent: false, - platform: 'node', - outfile: 'dist/extension.js', - external: ['vscode'], - logLevel: 'silent', - plugins: [ - /* add to the end of plugins array */ - esbuildProblemMatcherPlugin, - ], - }); - if (watch) { - await ctx.watch(); - } else { - await ctx.rebuild(); - await ctx.dispose(); - } + const ctx = await esbuild.context({ + entryPoints: ["src/extension.ts"], + bundle: true, + format: "cjs", + minify: production, + sourcemap: !production, + sourcesContent: false, + platform: "node", + outfile: "dist/extension.js", + external: ["vscode"], + logLevel: "silent", + plugins: [ + /* add to the end of plugins array */ + esbuildProblemMatcherPlugin, + ], + }); + if (watch) { + await ctx.watch(); + } else { + await ctx.rebuild(); + await ctx.dispose(); + } } -main().catch(e => { - console.error(e); - process.exit(1); +main().catch((e) => { + console.error(e); + process.exit(1); }); diff --git a/package-lock.json b/package-lock.json index 5a97de8..d1b3190 100644 --- a/package-lock.json +++ b/package-lock.json @@ -959,9 +959,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -979,9 +976,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -999,9 +993,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1019,9 +1010,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1039,9 +1027,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1059,9 +1044,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4222,9 +4204,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4246,9 +4225,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4270,9 +4246,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4294,9 +4267,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index d1319d0..fb3f5ba 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,11 @@ "displayName": "Gops", "description": "Visual Git Toolkit for VS Code", "version": "0.0.1", + "publisher": "codemanxdev", + "repository": { + "type": "git", + "url": "https://github.com/codemanxdev/gops.git" + }, "engines": { "vscode": "^1.110.0" }, @@ -37,11 +42,15 @@ "title": "New Branch", "icon": "$(git-branch)" }, + { + "command": "gops.branch.current", + "title": "Create Branch from Current", + "icon": "$(git-branch)" + }, { "command": "gops.checkout", "title": "Checkout Branch", - "icon": "$(git-branch)", - "enablement": "!gops.isCurrentBranch" + "icon": "$(git-branch)" }, { "command": "gops.deleteBranch", @@ -95,29 +104,19 @@ "group": "navigation" }, { - "command": "gops.branch", + "command": "gops.branch.current", "when": "view == gitOpsTreeview", "group": "navigation" } ], "view/item/context": [ { - "command": "gops.checkout", - "when": "view == gitOpsTreeview && viewItem == localBranches", - "group": "navigation" - }, - { - "command": "gops.deleteBranch", - "when": "view == gitOpsTreeview && viewItem == localBranches", - "group": "navigation" - }, - { - "command": "gops.renameBranch", - "when": "view == gitOpsTreeview && viewItem == localBranches", + "command": "gops.branch", + "when": "view == gitOpsTreeview && (viewItem == localBranches || viewItem == localBranches.current)", "group": "navigation" }, { - "command": "gops.tag", + "command": "gops.checkout", "when": "view == gitOpsTreeview && viewItem == localBranches", "group": "navigation" } @@ -131,6 +130,7 @@ "watch:esbuild": "node esbuild.js --watch", "watch:tsc": "tsc --noEmit --watch --project tsconfig.json", "package": "npm run check-types && npm run lint && node esbuild.js --production", + "build": "npm run package", "compile-tests": "tsc -p . --outDir out", "watch-tests": "tsc -p . -w --outDir out", "pretest": "npm run compile-tests && npm run compile && npm run lint", diff --git a/resources/gops-icon.svg b/resources/gops-icon.svg index 642b9ea..988626c 100644 --- a/resources/gops-icon.svg +++ b/resources/gops-icon.svg @@ -1,19 +1,4 @@ - - - - - - - - - - - - - - - - - - + + + \ No newline at end of file diff --git a/src/commands/CommandRegistrar.ts b/src/commands/CommandRegistrar.ts index 83de885..8da7f07 100644 --- a/src/commands/CommandRegistrar.ts +++ b/src/commands/CommandRegistrar.ts @@ -3,6 +3,7 @@ import { TreeDataProvider } from "../gopstree/TreeDataProvider"; import { GitService } from "../services/GitService"; import { COMMANDS } from "./Commands"; import { GitTreeNode } from "../gopstree/types"; +import { Logger } from "../logging/Logger"; export class CommandRegistrar { constructor( @@ -13,46 +14,104 @@ export class CommandRegistrar { registerAll() { this.register(COMMANDS.REFRESH, () => { - console.debug("executed command: ${COMMANDS.REFRESH}"); + console.debug(`executed command: ${COMMANDS.REFRESH}`); this.treeDataProvider.refresh(); }); - this.register(COMMANDS.CHECKOUT_BRANCH, (node: GitTreeNode) => { - console.debug("executed command: ${COMMANDS.CHECKOUT_BRANCH}"); + this.register(COMMANDS.CHECKOUT_BRANCH, async (node: GitTreeNode) => { + console.debug(`executed command: ${COMMANDS.CHECKOUT_BRANCH}`); if (node && "branchName" in node) { - this.gitService.checkout(node.branchName); + await this.gitService.checkout(node.branchName); this.treeDataProvider.refresh(node.parent); } }); - this.register(COMMANDS.DELETE_BRANCH, (node) => { - console.debug("executed command: ${COMMANDS.DELETE_BRANCH}"); + this.register(COMMANDS.DELETE_BRANCH, async (node: GitTreeNode) => { + console.debug(`executed command: ${COMMANDS.DELETE_BRANCH}`); //this.gitService.deleteBranch(node); }); - this.register(COMMANDS.RENAME_BRANCH, (node) => { - console.debug("executed command: ${COMMANDS.RENAME_BRANCH}"); + this.register(COMMANDS.RENAME_BRANCH, async (node: GitTreeNode) => { + console.debug(`executed command: ${COMMANDS.RENAME_BRANCH}`); //this.gitService.renameBranch(node); }); - this.register(COMMANDS.PUSH, () => { - console.debug("executed command: ${COMMANDS.PUSH}"); - this.gitService.push(); + this.register(COMMANDS.PUSH, async () => { + console.debug(`executed command: ${COMMANDS.PUSH}`); + await this.gitService.push(); }); - this.register(COMMANDS.PULL, () => { - console.debug("executed command: ${COMMANDS.PULL}"); - this.gitService.pull(); + this.register(COMMANDS.PULL, async () => { + console.debug(`executed command: ${COMMANDS.PULL}`); + await this.gitService.pull(); }); - this.register(COMMANDS.CREATE_BRANCH, (node) => { - console.debug("executed command: ${COMMANDS.CREATE_BRANCH}"); - this.gitService.createBranch(node); + this.register(COMMANDS.CREATE_BRANCH_FROM_CURRENT, async (node: GitTreeNode) => { + console.debug(`executed command: ${COMMANDS.CREATE_BRANCH_FROM_CURRENT}`); + const branchName: string | undefined = await vscode.window.showInputBox({ + prompt: "Enter new branch name", + placeHolder: "feature/my-new-feature", + ignoreFocusOut: true, + }); + + if (!branchName) { + return; + } + + await this.gitService.checkoutLocalBranch(branchName); + if (node?.parent) { + this.treeDataProvider.refresh(node.parent); + } }); + + this.register( + COMMANDS.CREATE_BRANCH, + async (node: GitTreeNode) => { + console.debug( + `executed command: ${COMMANDS.CREATE_BRANCH}`, + ); + if (!node || !("branchName" in node)) { + return; + } + + const baseBranch = node?.branchName; + const branchName: string | undefined = await vscode.window.showInputBox( + { + prompt: `Enter new branch name to create from ${baseBranch}`, + placeHolder: "feature/my-new-feature", + ignoreFocusOut: true, + }, + ); + + if (!branchName) { + return; + } + + await this.gitService.checkoutBranch(branchName, baseBranch); + if (node?.parent) { + this.treeDataProvider.refresh(node.parent); + } + }, + ); } - private register(command: string, handler: (...args: any[]) => any) { - const disposable = vscode.commands.registerCommand(command, handler); + private register( + command: string, + handler: (...args: any[]) => Promise | void, + ) { + const disposable = vscode.commands.registerCommand( + command, + async (...args) => { + try { + await handler(...args); + } catch (error) { + Logger.error( + `Error executing command ${command}: ${Logger.getErrorMessage(error)}`, + ); + } + }, + ); + this.context.subscriptions.push(disposable); } } diff --git a/src/commands/Commands.ts b/src/commands/Commands.ts index 264d159..af63440 100644 --- a/src/commands/Commands.ts +++ b/src/commands/Commands.ts @@ -5,6 +5,7 @@ export const COMMANDS = { RENAME_BRANCH: "gops.renameBranch", PUSH: "gops.push", PULL: "gops.pull", + CREATE_BRANCH_FROM_CURRENT: "gops.branch.current", CREATE_BRANCH: "gops.branch", CREATE_TAG: "gops.tag", } as const; diff --git a/src/constants/Constants.ts b/src/constants/Constants.ts index d2e497c..f3806d5 100644 --- a/src/constants/Constants.ts +++ b/src/constants/Constants.ts @@ -4,6 +4,7 @@ export class Constants { static readonly REMOTE_BRANCHES_LABEL = 'Remote Branches'; static readonly CHANGES_LABEL = 'Changes'; static readonly TAGS_LABEL = 'Tags'; + static readonly STASH_LABEL = 'Stash'; //Logging static readonly LOGGER_CHANNEL = "Gops"; diff --git a/src/extension.ts b/src/extension.ts index 31d9578..62c7105 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,15 +2,11 @@ import * as vscode from "vscode"; import { TreeDataProvider } from "./gopstree/TreeDataProvider"; import { GitService } from "./services/GitService"; import { CommandRegistrar } from "./commands/CommandRegistrar"; -import { TreeSelectionController } from "./gopstree/controller/TreeSelectionController"; -import { MenuContextService } from "./gopstree/context/MenuContexttService"; export function activate(context: vscode.ExtensionContext) { const gitService = new GitService(); const treeDataProvider = new TreeDataProvider(gitService); - const treeView = vscode.window.createTreeView("gitOpsTreeview", { - treeDataProvider, - }); + vscode.window.createTreeView("gitOpsTreeview", {treeDataProvider}); // Register commands const registrar = new CommandRegistrar( @@ -20,12 +16,6 @@ export function activate(context: vscode.ExtensionContext) { ); registrar.registerAll(); - // Register tree selection controller - const menuContext = new MenuContextService(); - const selectionController = new TreeSelectionController(treeView, menuContext); - context.subscriptions.push(selectionController.register()); - - console.log("Gops extension activated."); } diff --git a/src/gopstree/ContextValue.ts b/src/gopstree/ContextValue.ts index 2eb6510..b584d9f 100644 --- a/src/gopstree/ContextValue.ts +++ b/src/gopstree/ContextValue.ts @@ -2,6 +2,7 @@ export enum ContextValue { Repository = "repository", Changes = "changes", LocalBranches = "localBranches", + LocalBranchesCurrent = "localBranches.current", RemoteBranches = "remoteBranches", Branch = "branch", File = "file", diff --git a/src/gopstree/TreeDataProvider.ts b/src/gopstree/TreeDataProvider.ts index 20b67f5..14de4b2 100644 --- a/src/gopstree/TreeDataProvider.ts +++ b/src/gopstree/TreeDataProvider.ts @@ -25,7 +25,7 @@ export class TreeDataProvider implements vscode.TreeDataProvider { async getChildren(element?: TreeItemModel): Promise { //Root level: show repository if (!element) { - const repoName = await this.gitService.getRepoName(); + const repoName = this.gitService.getRepoName(); const currentBranch = await this.gitService.getCurrentBranch(); return [new RepositoryNode(repoName, currentBranch)]; } @@ -38,6 +38,12 @@ export class TreeDataProvider implements vscode.TreeDataProvider { return this.getLocalBranches(element); case NodeType.Remote: return this.getRemoteBranches(); + case NodeType.Changes: + return this.getChanges(); + case NodeType.Tags: + return this.getTags(); + case NodeType.Stash: + return this.getStash(); default: return []; } @@ -65,6 +71,31 @@ export class TreeDataProvider implements vscode.TreeDataProvider { ); } + private async getChanges(): Promise { + const status = await this.gitService.getStatus(); + const changedFiles = [ + ...status.modified, + ...status.not_added, + ...status.created, + ...status.deleted, + ...status.renamed.map((f) => f.to), + ]; + + return changedFiles.map( + (f) => new TreeItemModel({ label: f }, NodeType.File, vscode.TreeItemCollapsibleState.None) + ); + } + + private async getTags(): Promise { + const tags = await this.gitService.getTags(); + return tags.map((t) => new TreeItemModel({ label: t }, NodeType.Tags, vscode.TreeItemCollapsibleState.None)); + } + + private async getStash(): Promise { + const stash = await this.gitService.getStash(); + return stash.map((s) => new TreeItemModel({ label: s }, NodeType.Stash, vscode.TreeItemCollapsibleState.None)); + } + private getRepositoryChildren(): TreeItemModel[] { const localBranchesItem = new TreeItemModel( { label: Constants.LOCAL_BRANCHES_LABEL }, @@ -82,19 +113,26 @@ export class TreeDataProvider implements vscode.TreeDataProvider { const changesItem = new TreeItemModel( { label: Constants.CHANGES_LABEL }, - NodeType.Section, + NodeType.Changes, vscode.TreeItemCollapsibleState.Collapsed, ); changesItem.iconPath = new vscode.ThemeIcon("diff"); const tagsItem = new TreeItemModel( { label: Constants.TAGS_LABEL }, - NodeType.Section, + NodeType.Tags, vscode.TreeItemCollapsibleState.Collapsed, ); tagsItem.iconPath = new vscode.ThemeIcon("tag"); - return [localBranchesItem, remoteBranchesItem, changesItem, tagsItem]; + const stashItem = new TreeItemModel( + { label: Constants.STASH_LABEL }, + NodeType.Stash, + vscode.TreeItemCollapsibleState.Collapsed, + ); + stashItem.iconPath = new vscode.ThemeIcon("save"); + + return [localBranchesItem, remoteBranchesItem, changesItem, tagsItem, stashItem]; } refresh(node?: GitTreeNode): void { diff --git a/src/gopstree/context/Contexts.ts b/src/gopstree/context/Contexts.ts deleted file mode 100644 index 742cb5a..0000000 --- a/src/gopstree/context/Contexts.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const CONTEXTS = { - IS_CURRENT_BRANCH: "gops.isCurrentBranch", -} as const; \ No newline at end of file diff --git a/src/gopstree/context/MenuContexttService.ts b/src/gopstree/context/MenuContexttService.ts deleted file mode 100644 index 9a8b4b3..0000000 --- a/src/gopstree/context/MenuContexttService.ts +++ /dev/null @@ -1,7 +0,0 @@ -import * as vscode from "vscode"; - -export class MenuContextService { - async set(key: string, value: any) { - await vscode.commands.executeCommand("setContext", key, value); - } -} diff --git a/src/gopstree/controller/TreeSelectionController.ts b/src/gopstree/controller/TreeSelectionController.ts deleted file mode 100644 index dd02d37..0000000 --- a/src/gopstree/controller/TreeSelectionController.ts +++ /dev/null @@ -1,32 +0,0 @@ -import * as vscode from "vscode"; -import { MenuContextService } from "../context/MenuContexttService"; -import { CONTEXTS } from "../context/Contexts"; -import { GitTreeNode } from "../types"; -import { NodeType } from "../nodes/NodeType"; -import { LocalBranchNode } from "../nodes/LocalBranchNode"; - -export class TreeSelectionController { - constructor( - private treeView: vscode.TreeView, - private menuContext: MenuContextService, - ) {} - - register() { - return this.treeView.onDidChangeSelection((e) => { - const node = e.selection[0]; - - const isCurrent = this.isLocalBranchNode(node) && node.isCurrent === true; - - if (node.type === NodeType.Local) { - this.menuContext.set( - CONTEXTS.IS_CURRENT_BRANCH, - isCurrent, - ); - } - }); - } - - private isLocalBranchNode(node: GitTreeNode): node is LocalBranchNode { - return node.type === NodeType.Local; - } -} diff --git a/src/gopstree/nodes/LocalBranchNode.ts b/src/gopstree/nodes/LocalBranchNode.ts index b1413c1..148a1d4 100644 --- a/src/gopstree/nodes/LocalBranchNode.ts +++ b/src/gopstree/nodes/LocalBranchNode.ts @@ -20,7 +20,7 @@ export class LocalBranchNode extends TreeItemModel { NodeType.Local, vscode.TreeItemCollapsibleState.None, ); - this.contextValue = ContextValue.LocalBranches.toString(); + this.contextValue = isCurrent ? ContextValue.LocalBranchesCurrent : ContextValue.LocalBranches; if (isCurrent) { this.iconPath = new vscode.ThemeIcon("check"); diff --git a/src/gopstree/nodes/NodeType.ts b/src/gopstree/nodes/NodeType.ts index e5f4759..071c607 100644 --- a/src/gopstree/nodes/NodeType.ts +++ b/src/gopstree/nodes/NodeType.ts @@ -1,9 +1,18 @@ export enum NodeType { + //Root level Repository = "repository", - Section = "section", - Branch = "branch", + + //Main sections Local = "local", Remote = "remote", + Changes = "changes", + Tags = "tags", + Stash = "stash", + + //Changes section File = "file", - Stash = "stash" + + //Unused + Section = "section", + Branch = "branch", } diff --git a/src/gopstree/types.ts b/src/gopstree/types.ts index 7aa08a6..6ffc3f3 100644 --- a/src/gopstree/types.ts +++ b/src/gopstree/types.ts @@ -7,4 +7,4 @@ export type GitTreeNode = | LocalBranchNode | RemoteBranchNode | RepositoryNode - | TreeItemModel; + | TreeItemModel; \ No newline at end of file diff --git a/src/services/AheadBehindModel.ts b/src/services/AheadBehindModel.ts new file mode 100644 index 0000000..b405ac9 --- /dev/null +++ b/src/services/AheadBehindModel.ts @@ -0,0 +1,4 @@ +export interface AheadBehindModel { + ahead: number; + behind: number; +} diff --git a/src/services/GitService.ts b/src/services/GitService.ts index 67a8045..5a68d8c 100644 --- a/src/services/GitService.ts +++ b/src/services/GitService.ts @@ -1,8 +1,11 @@ -import simpleGit, { SimpleGit } from "simple-git"; +import simpleGit, { BranchSummary, DefaultLogFields, ListLogLine, RemoteWithoutRefs, SimpleGit, StatusResult } from "simple-git"; import * as vscode from "vscode"; import * as path from "path"; import { Logger } from "../logging/Logger"; import { Notifications } from "../notifications/Notifications"; +import { LocalBranchModel } from "./LocalBranchModel"; +import { RemoteBranchModel } from "./RemoteBranchModel"; +import { AheadBehindModel } from "./AheadBehindModel"; export class GitService { private git: SimpleGit; @@ -13,37 +16,42 @@ export class GitService { constructor(repoPath?: string) { const finalPath = repoPath ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + + if (!finalPath) { + throw new Error("No workspace folder is open"); + } + this.git = simpleGit(finalPath); } - async getStatus() { + async getStatus(): Promise { return this.git.status(); } - async getBranches() { + async getBranches(): Promise { return this.git.branch(); } - async getLocalBranches() { + async getLocalBranches(): Promise { const branches = await this.git.branchLocal(); - return branches.all.map((name) => ({ - name, - current: name === branches.current, - ahead: branches.branches[name].label.match(/ahead (\d+)/) - ? parseInt(branches.branches[name].label.match(/ahead (\d+)/)![1], 10) - : 0, - behind: branches.branches[name].label.match(/behind (\d+)/) - ? parseInt(branches.branches[name].label.match(/behind (\d+)/)![1], 10) - : 0, - })); - } - - async getRemotes() { + + return branches.all.map((name) => { + const branch = branches.branches[name]; + + return { + name, + current: name === branches.current, + ...this.parseAheadBehind(branch.label), + }; + }); + } + + async getRemotes(): Promise { return this.git.getRemotes(); } - async getRemoteBranches(remote: string) { - const branches = await this.git.branch({ '--remotes': null }); + async getRemoteBranches(remote: string): Promise { + const branches = await this.git.branch({ "--remotes": null }); return branches.all .filter((name) => name.startsWith(`${remote}/`)) .map((name) => ({ @@ -52,40 +60,72 @@ export class GitService { })); } - async getLog() { + async getTags(): Promise { + return (await this.git.tags()).all; + } + + async getStash(): Promise { + const stashList = await this.git.stashList(); + return stashList.all.map((s) => s.message); + } + + async getLog(): Promise { const log = await this.git.log(); return log.all; } - async checkout(branch: string) { - try { - await this.git.checkout(branch); - Logger.info(`Checked out to ${branch}`); - Notifications.info(`Checked out to ${branch}`); - } catch (error: unknown) { - const message = Logger.getErrorMessage(error); - Logger.error(`Checkout failed: ${message}`); - Notifications.errorWithOutput("Checkout failed. See details in output"); - } - } + // #region [Branch Operations] - async commit(message: string) { - return this.git.commit(message); + async checkout(branch: string) { + return this.executeGitAction( + () => this.git.checkout(branch), + `Checked out branch ${branch} successfully`, + `Checkout failed for branch ${branch}`, + ); } - async pull() { - return this.git.pull(); + async checkoutBranch(newBranch: string, baseBranch: string) { + return this.executeGitAction( + () => this.git.checkoutBranch(newBranch, baseBranch), + `Checked out branch ${newBranch} successfully`, + `Checkout failed for branch ${newBranch}`, + ); } async push() { - return this.git.push(); + return this.executeGitAction( + () => this.git.push(), + "Pushed to remote successfully", + "Push failed", + ); + } + + async commit(message: string) { + return this.executeGitAction( + () => this.git.commit(message), + "Commit successful", + "Commit failed", + ); } - async createBranch(branchName: string) { - return this.git.checkoutLocalBranch(branchName); + async pull() { + return this.executeGitAction( + () => this.git.pull(), + "Pull successful", + "Pull failed", + ); + } + + async checkoutLocalBranch(branchName: string) { + return this.executeGitAction( + () => this.git.checkoutLocalBranch(branchName), + `Branch ${branchName} created successfully`, + `Failed to create branch ${branchName}`, + ); } + // #endregion - async getRepoName(): Promise { + getRepoName(): string { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; const repoName = workspaceFolder @@ -103,4 +143,32 @@ export class GitService { const branchSummary = await this.git.branch(); return branchSummary.current; } + + private async executeGitAction( + action: () => Promise, + successMessage: string, + failurePrefix: string, + ): Promise { + try { + const result = await action(); + Logger.info(successMessage); + Notifications.info(successMessage); + return result; + } catch (error) { + const message = Logger.getErrorMessage(error); + Logger.error(`${failurePrefix}: ${message}`); + Notifications.errorWithOutput(`${failurePrefix}. See details in output`); + throw error; + } + } + + private parseAheadBehind(label: string): AheadBehindModel { + const aheadMatch = label.match(/ahead (\d+)/); + const behindMatch = label.match(/behind (\d+)/); + + return { + ahead: aheadMatch ? parseInt(aheadMatch[1], 10) : 0, + behind: behindMatch ? parseInt(behindMatch[1], 10) : 0, + }; + } } diff --git a/src/services/LocalBranchModel.ts b/src/services/LocalBranchModel.ts new file mode 100644 index 0000000..e1d45f6 --- /dev/null +++ b/src/services/LocalBranchModel.ts @@ -0,0 +1,6 @@ +export interface LocalBranchModel { + name: string; + current: boolean; + ahead: number; + behind: number; +} diff --git a/src/services/RemoteBranchModel.ts b/src/services/RemoteBranchModel.ts new file mode 100644 index 0000000..f1b6bdd --- /dev/null +++ b/src/services/RemoteBranchModel.ts @@ -0,0 +1,4 @@ +export interface RemoteBranchModel { + name: string; + remote: string; +} diff --git a/test/unit/gopstree/controller/TreeSelectionController.test.ts b/test/unit/gopstree/controller/TreeSelectionController.test.ts deleted file mode 100644 index 6e175b7..0000000 --- a/test/unit/gopstree/controller/TreeSelectionController.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { TreeSelectionController } from "../../../../src/gopstree/controller/TreeSelectionController"; -import { CONTEXTS } from "../../../../src/gopstree/context/Contexts"; -import { NodeType } from "../../../../src/gopstree/nodes/NodeType"; - -const createMockNode = (overrides: Record = {}) => ({ - type: NodeType.Local, - isCurrent: false, - ...overrides, -}); - -const createMockTreeView = (subscriptionCallback: (e: { selection: unknown[] }) => void) => ({ - onDidChangeSelection: vi.fn((cb) => { - subscriptionCallback.cb = cb; - return { dispose: vi.fn() }; - }), - __emitSelection: (selection: unknown[]) => { - subscriptionCallback.cb?.({ selection }); - }, -}); - -const createMockMenuContext = () => ({ - set: vi.fn(), -}); - -describe("TreeSelectionController", () => { - let controller: TreeSelectionController; - let mockTreeView: ReturnType; - let mockMenuContext: ReturnType; - - beforeEach(() => { - mockMenuContext = createMockMenuContext(); - mockTreeView = createMockTreeView({} as never); - controller = new TreeSelectionController( - mockTreeView as never, - mockMenuContext as never, - ); - }); - - describe("register", () => { - it("should return a disposable from treeView.onDidChangeSelection", () => { - const result = controller.register(); - - expect(mockTreeView.onDidChangeSelection).toHaveBeenCalled(); - expect(result).toEqual({ dispose: expect.any(Function) }); - }); - - it("should set IS_CURRENT_BRANCH context to true when current branch is selected", () => { - controller.register(); - const currentBranchNode = createMockNode({ type: NodeType.Local, isCurrent: true }); - - mockTreeView.__emitSelection([currentBranchNode]); - - expect(mockMenuContext.set).toHaveBeenCalledWith( - CONTEXTS.IS_CURRENT_BRANCH, - true, - ); - }); - - it("should set IS_CURRENT_BRANCH context to false when non-current branch is selected", () => { - controller.register(); - const nonCurrentBranchNode = createMockNode({ type: NodeType.Local, isCurrent: false }); - - mockTreeView.__emitSelection([nonCurrentBranchNode]); - - expect(mockMenuContext.set).toHaveBeenCalledWith( - CONTEXTS.IS_CURRENT_BRANCH, - false, - ); - }); - - it("should not set context for non-Local node types", () => { - controller.register(); - const nonLocalNode = createMockNode({ type: NodeType.Remote, isCurrent: true }); - - mockTreeView.__emitSelection([nonLocalNode]); - - expect(mockMenuContext.set).not.toHaveBeenCalled(); - }); - }); -}); \ No newline at end of file