diff --git a/.vscode/settings.json b/.vscode/settings.json index f612a74..f1651f2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,14 +1,14 @@ // Place your settings in this file to overwrite default and user settings. { "files.exclude": { - "out": false, // set this to true to hide the "out" folder with the compiled JS files - "dist": false // set this to true to hide the "dist" folder with the compiled JS files + "out": true, + "dist": true }, "search.exclude": { "out": true, // set this to false to include "out" folder in search results "dist": true // set this to false to include "dist" folder in search results }, // Turn off tsc task auto detection since we have the necessary tasks as npm scripts - "typescript.tsc.autoDetect": "off", + "js/ts.tsc.autoDetect": "off", "snyk.advanced.autoSelectOrganization": true } diff --git a/README.md b/README.md index ed8cfc3..fda85bd 100644 --- a/README.md +++ b/README.md @@ -15,18 +15,21 @@ Git Operations - Visual Git Toolkit for VS Code ### Tree View - Toolbar with following options - - Refresh treeview - - Push + - Commit + - Create Branch from Current - Pull + - Push + - Refresh treeview +- Local Branches + - Checkout + - Delete - New Branch -- Local and Remote branches -- Ability to interact and perform the following operations - - Checkout branch - - Create tag - - Delete branch - - Rename branch + - Rename Branch +- Remote Branches - Local Changes +- Staged Changes - Tags +- Stash ## Requirements diff --git a/package.json b/package.json index d96b9f6..21901ad 100644 --- a/package.json +++ b/package.json @@ -111,12 +111,17 @@ { "command": "gops.commit", "title": "Commit", - "icon": "$(save)" + "icon": "$(git-commit)" }, { "command": "gops.unstageAllFiles", "title": "Unstage All Files", "icon": "$(collapse-all)" + }, + { + "command": "gops.stageAllFiles", + "title": "Stage All Files", + "icon": "$(expand-all)" } ], "viewsContainers": { @@ -195,6 +200,24 @@ "title": "Unstage All Files", "when": "view == gitOpsTreeview && viewItem == stagedChangesSection", "group": "inline" + }, + { + "command": "gops.stageAllFiles", + "title": "Stage All Files", + "when": "view == gitOpsTreeview && viewItem == changesSection", + "group": "inline" + }, + { + "command": "gops.deleteBranch", + "title": "Delete Branch", + "when": "view == gitOpsTreeview && viewItem == localBranches", + "group": "navigation" + }, + { + "command": "gops.renameBranch", + "title": "Rename Branch", + "when": "view == gitOpsTreeview && viewItem == localBranches", + "group": "navigation" } ] } diff --git a/src/commands/CommandRegistrar.ts b/src/commands/CommandRegistrar.ts index fda0a12..7ddd061 100644 --- a/src/commands/CommandRegistrar.ts +++ b/src/commands/CommandRegistrar.ts @@ -36,6 +36,9 @@ export class CommandRegistrar { this.register(COMMANDS.UNSTAGE_ALL_FILES, () => this.delegate.unstageAllFiles(), ); + this.register(COMMANDS.STAGE_ALL_FILES, () => + this.delegate.stageAllFiles(), + ); this.register(COMMANDS.COMMIT, () => this.delegate.commit()); this.register(COMMANDS.CREATE_TAG, () => this.delegate.createTag()); } diff --git a/src/commands/Commands.ts b/src/commands/Commands.ts index 986fa62..c699e0b 100644 --- a/src/commands/Commands.ts +++ b/src/commands/Commands.ts @@ -10,6 +10,7 @@ export const COMMANDS = { CREATE_TAG: "gops.tag", SHOW_DIFF: "gops.showDiff", STAGE_FILE: "gops.stageFile", + STAGE_ALL_FILES: "gops.stageAllFiles", UNSTAGE_FILE: "gops.unstageFile", UNSTAGE_ALL_FILES: "gops.unstageAllFiles", COMMIT: "gops.commit", diff --git a/src/commands/GitOperationsDelegate.ts b/src/commands/GitOperationsDelegate.ts index 8f977e4..2f8136a 100644 --- a/src/commands/GitOperationsDelegate.ts +++ b/src/commands/GitOperationsDelegate.ts @@ -32,14 +32,39 @@ export class GitOperationsDelegate { if (!node || !("branchName" in node)) { return; } - // TODO: implement + + const confirm = await vscode.window.showWarningMessage( + `Are you sure you want to delete branch "${node.branchName}"?`, + { modal: true }, + "Delete", + ); + + if (confirm !== "Delete") { + return; + } + + await this.gitService.deleteBranch(node.branchName); + await this.treeDataProvider.refreshLocalBranchesNode(); } async renameBranch(node: GitTreeNode): Promise { if (!node || !("branchName" in node)) { return; } - // TODO: implement + + const newName = await vscode.window.showInputBox({ + prompt: `Enter new name for branch "${node.branchName}"`, + placeHolder: "feature/my-new-name", + value: node.branchName, + ignoreFocusOut: true, + }); + + if (!newName || newName === node.branchName) { + return; + } + + await this.gitService.renameBranch(node.branchName, newName); + await this.treeDataProvider.refreshLocalBranchesNode(); } async push(): Promise { @@ -124,6 +149,12 @@ export class GitOperationsDelegate { await this.treeDataProvider.refreshStagedNode(); } + async stageAllFiles(): Promise { + await this.gitService.stageAllFiles(); + await this.treeDataProvider.refreshChangesNode(); + await this.treeDataProvider.refreshStagedNode(); + } + async commit(): Promise { const message = await vscode.window.showInputBox({ prompt: "Enter commit message", diff --git a/src/constants/ContextKeys.ts b/src/constants/ContextKeys.ts new file mode 100644 index 0000000..90aa802 --- /dev/null +++ b/src/constants/ContextKeys.ts @@ -0,0 +1,4 @@ +export const CONTEXT_KEYS = { + HAS_STAGED_FILES: "gops.hasStagedFiles", + HAS_CHANGED_FILES: "gops.hasChangedFiles", +} as const; diff --git a/src/gopstree/ContextValue.ts b/src/gopstree/ContextValue.ts index 87ba805..83a9961 100644 --- a/src/gopstree/ContextValue.ts +++ b/src/gopstree/ContextValue.ts @@ -12,6 +12,7 @@ export enum ContextValue { LocalBranchesSection = "localBranchesSection", RemoteBranchesSection = "remoteBranchesSection", ChangesSection = "changesSection", + ChangesSectionEmpty = "changesSectionEmpty", StagedChangesSection = "stagedChangesSection", StagedChangesSectionEmpty = "stagedChangesSectionEmpty", TagsSection = "tagsSection", diff --git a/src/gopstree/TreeDataProvider.ts b/src/gopstree/TreeDataProvider.ts index 63b7b19..be439f5 100644 --- a/src/gopstree/TreeDataProvider.ts +++ b/src/gopstree/TreeDataProvider.ts @@ -5,7 +5,6 @@ import { NodeType } from "./nodes/NodeType"; import { RepositoryNode } from "./nodes/RepositoryNode"; import { LocalBranchNode } from "./nodes/LocalBranchNode"; import { RemoteBranchNode } from "./nodes/RemoteBranchNode"; -import { Constants } from "../constants/Constants"; import { GitTreeNode } from "./types"; import { Notifications } from "../notifications/Notifications"; import { ChangedFileNode } from "./nodes/ChangedFileNode"; @@ -17,6 +16,7 @@ import { StagedChangesSection } from "./nodes/StagedChangesSection"; import { TagsSection } from "./nodes/TagsSection"; import { StashSection } from "./nodes/StashSection"; import { ContextValue } from "./ContextValue"; +import { CONTEXT_KEYS } from "../constants/ContextKeys"; export class TreeDataProvider implements vscode.TreeDataProvider { private _onDidChangeTreeData = new vscode.EventEmitter< @@ -135,13 +135,18 @@ export class TreeDataProvider implements vscode.TreeDataProvider { } private async getRepositoryChildren(): Promise { - const stagedFiles = await this.gitService.getStagedFiles(); + const [stagedFiles, changedFiles] = await Promise.all([ + this.gitService.getStagedFiles(), + this.gitService.getChangedFiles(), + ]); + const hasStagedFiles = stagedFiles.length > 0; - await vscode.commands.executeCommand( - "setContext", - "gops.hasStagedFiles", - hasStagedFiles, - ); + const hasChangedFiles = changedFiles.length > 0; + + await Promise.all([ + this.setContext(CONTEXT_KEYS.HAS_STAGED_FILES, hasStagedFiles), + this.setContext(CONTEXT_KEYS.HAS_CHANGED_FILES, hasChangedFiles), + ]); const localBranchesItem = new LocalBranchesSection( this.localBranchesNode?.collapsibleState || @@ -159,12 +164,18 @@ export class TreeDataProvider implements vscode.TreeDataProvider { this.changesNode?.collapsibleState || vscode.TreeItemCollapsibleState.Collapsed, ); + changesItem.contextValue = hasChangedFiles + ? ContextValue.ChangesSection + : ContextValue.ChangesSectionEmpty; this.changesNode = changesItem; const stagedItem = new StagedChangesSection( this.stagedNode?.collapsibleState || vscode.TreeItemCollapsibleState.Collapsed, ); + stagedItem.contextValue = hasStagedFiles + ? ContextValue.StagedChangesSection + : ContextValue.StagedChangesSectionEmpty; this.stagedNode = stagedItem; const tagsItem = new TagsSection( @@ -189,6 +200,10 @@ export class TreeDataProvider implements vscode.TreeDataProvider { ]; } + private async setContext(key: string, value: boolean): Promise { + await vscode.commands.executeCommand("setContext", key, value); + } + refresh(node?: GitTreeNode): void { this._onDidChangeTreeData.fire(node); @@ -228,6 +243,12 @@ export class TreeDataProvider implements vscode.TreeDataProvider { if (!this.changesNode) { return; } + const changedFiles = await this.gitService.getChangedFiles(); + const hasChangedFiles = changedFiles.length > 0; + await this.setContext(CONTEXT_KEYS.HAS_CHANGED_FILES, hasChangedFiles); + this.changesNode.contextValue = hasChangedFiles + ? ContextValue.ChangesSection + : ContextValue.ChangesSectionEmpty; this._onDidChangeTreeData.fire(this.changesNode); } @@ -237,11 +258,7 @@ export class TreeDataProvider implements vscode.TreeDataProvider { } const stagedFiles = await this.gitService.getStagedFiles(); const hasStagedFiles = stagedFiles.length > 0; - await vscode.commands.executeCommand( - "setContext", - "gops.hasStagedFiles", - hasStagedFiles, - ); + await this.setContext(CONTEXT_KEYS.HAS_STAGED_FILES, hasStagedFiles); this.stagedNode.contextValue = hasStagedFiles ? ContextValue.StagedChangesSection : ContextValue.StagedChangesSectionEmpty; diff --git a/src/gopstree/nodes/ChangedFileNode.ts b/src/gopstree/nodes/ChangedFileNode.ts index c793c02..4dba7e2 100644 --- a/src/gopstree/nodes/ChangedFileNode.ts +++ b/src/gopstree/nodes/ChangedFileNode.ts @@ -1,15 +1,16 @@ import { ContextValue } from "../ContextValue"; import { NodeType } from "./NodeType"; import { TreeItemModel } from "../TreeItemModel"; -import * as vscode from 'vscode'; -import { createChangedFileTooltip, formatChangedFileLabel } from "./utils/nodeUtils"; +import * as vscode from "vscode"; +import { + createChangedFileTooltip, + formatChangedFileLabel, +} from "./utils/nodeUtils"; import { COMMANDS } from "../../commands/Commands"; export class ChangedFileNode extends TreeItemModel { public override command?: vscode.Command; - constructor( - public readonly fileName: string, - ) { + constructor(public readonly fileName: string) { const fomatted = formatChangedFileLabel(fileName); super( { @@ -25,13 +26,11 @@ export class ChangedFileNode extends TreeItemModel { command: COMMANDS.SHOW_DIFF, arguments: [this], }; - - this.tooltip = createChangedFileTooltip( - fileName, - ); + + this.tooltip = createChangedFileTooltip(fileName); } public toString(): string { return `ChangedFileNode(${this.fileName}, contextValue=${this.contextValue})`; } -} \ No newline at end of file +} diff --git a/src/services/GitService.ts b/src/services/GitService.ts index 6706704..8b3f7ff 100644 --- a/src/services/GitService.ts +++ b/src/services/GitService.ts @@ -73,6 +73,30 @@ export class GitService { ); } + async stageAllFiles(): Promise { + await this.executeGitAction( + () => this.git.add("."), + "Staged all files successfully", + "Failed to stage all files", + ); + } + + async deleteBranch(branchName: string): Promise { + await this.executeGitAction( + () => this.git.deleteLocalBranch(branchName), + `Branch ${branchName} deleted successfully`, + `Failed to delete branch ${branchName}`, + ); + } + + async renameBranch(oldName: string, newName: string): Promise { + await this.executeGitAction( + () => this.git.raw(["branch", "-m", oldName, newName]), + `Branch renamed to ${newName} successfully`, + `Failed to rename branch ${oldName}`, + ); + } + async getBranches(): Promise { return this.git.branch(); } diff --git a/test/integration/branch.test.ts b/test/integration/branch.test.ts index 4b5d51c..5bfe7fd 100644 --- a/test/integration/branch.test.ts +++ b/test/integration/branch.test.ts @@ -1,5 +1,6 @@ import * as assert from "node:assert"; import * as vscode from "vscode"; +import { COMMANDS } from "../../src/commands/Commands"; suite("Branch", function () { this.timeout(30000); @@ -11,29 +12,29 @@ suite("Branch", function () { } }); - test("gops.branch.current should execute without error", async function () { + test(`${COMMANDS.CREATE_BRANCH_FROM_CURRENT} should execute without error`, async function () { // Mock input box to simulate cancel const stub = vscode.window.showInputBox; (vscode.window as any).showInputBox = async () => undefined; - await vscode.commands.executeCommand("gops.branch.current"); + await vscode.commands.executeCommand(COMMANDS.CREATE_BRANCH_FROM_CURRENT); (vscode.window as any).showInputBox = stub; - assert.ok(true, "gops.branch.current completed without error"); + assert.ok(true, `${COMMANDS.CREATE_BRANCH_FROM_CURRENT} completed without error`); }); - test("gops.deleteBranch should execute without error", async function () { - await vscode.commands.executeCommand("gops.deleteBranch"); - assert.ok(true, "gops.deleteBranch completed without error"); + test(`${COMMANDS.DELETE_BRANCH} should execute without error`, async function () { + await vscode.commands.executeCommand(COMMANDS.DELETE_BRANCH); + assert.ok(true, `${COMMANDS.DELETE_BRANCH} completed without error`); }); - test("gops.renameBranch should execute without error", async function () { - await vscode.commands.executeCommand("gops.renameBranch"); - assert.ok(true, "gops.renameBranch completed without error"); + test(`${COMMANDS.RENAME_BRANCH} should execute without error`, async function () { + await vscode.commands.executeCommand(COMMANDS.RENAME_BRANCH); + assert.ok(true, `${COMMANDS.RENAME_BRANCH} completed without error`); }); - test("gops.checkout should execute without error", async function () { - await vscode.commands.executeCommand("gops.checkout"); - assert.ok(true, "gops.checkout completed without error"); + test(`${COMMANDS.CHECKOUT_BRANCH} should execute without error`, async function () { + await vscode.commands.executeCommand(COMMANDS.CHECKOUT_BRANCH); + assert.ok(true, `${COMMANDS.CHECKOUT_BRANCH} completed without error`); }); }); diff --git a/test/integration/commands.test.ts b/test/integration/commands.test.ts index b1805f3..8d7112c 100644 --- a/test/integration/commands.test.ts +++ b/test/integration/commands.test.ts @@ -1,5 +1,6 @@ import * as assert from "node:assert"; import * as vscode from "vscode"; +import { COMMANDS } from "../../src/commands/Commands"; suite("Commands", function () { this.timeout(30000); @@ -14,20 +15,20 @@ suite("Commands", function () { test("should register all commands", async function () { const commands = await vscode.commands.getCommands(true); const expectedCommands = [ - "gops.refresh", - "gops.pull", - "gops.push", - "gops.checkout", - "gops.deleteBranch", - "gops.renameBranch", - "gops.branch", - "gops.branch.current", - "gops.tag", - "gops.showDiff", - "gops.stageFile", - "gops.unstageFile", - "gops.unstageAllFiles", - "gops.commit", + COMMANDS.REFRESH, + COMMANDS.PULL, + COMMANDS.PUSH, + COMMANDS.CHECKOUT_BRANCH, + COMMANDS.DELETE_BRANCH, + COMMANDS.RENAME_BRANCH, + COMMANDS.CREATE_BRANCH, + COMMANDS.CREATE_BRANCH_FROM_CURRENT, + COMMANDS.CREATE_TAG, + COMMANDS.SHOW_DIFF, + COMMANDS.STAGE_FILE, + COMMANDS.UNSTAGE_FILE, + COMMANDS.UNSTAGE_ALL_FILES, + COMMANDS.COMMIT, ]; for (const command of expectedCommands) { @@ -38,31 +39,31 @@ suite("Commands", function () { } }); - test("gops.refresh should execute without error", async function () { - await vscode.commands.executeCommand("gops.refresh"); - assert.ok(true, "gops.refresh completed without error"); + test(`${COMMANDS.REFRESH} should execute without error`, async function () { + await vscode.commands.executeCommand(COMMANDS.REFRESH); + assert.ok(true, "${COMMANDS.REFRESH} completed without error"); }); - test("gops.pull should execute without error", async function () { + test(`${COMMANDS.PULL} should execute without error`, async function () { try { - await vscode.commands.executeCommand("gops.pull"); - assert.ok(true, "gops.pull completed without error"); + await vscode.commands.executeCommand(COMMANDS.PULL); + assert.ok(true, `${COMMANDS.PULL} completed without error`); } catch (err: any) { assert.ok( err.message.includes("remote") || err.message.includes("origin"), - `gops.pull failed with unexpected error: ${err.message}`, + `${COMMANDS.PULL} failed with unexpected error: ${err.message}`, ); } }); - test("gops.push should execute without error", async function () { + test(`${COMMANDS.PUSH} should execute without error`, async function () { try { - await vscode.commands.executeCommand("gops.push"); - assert.ok(true, "gops.push completed without error"); + await vscode.commands.executeCommand(COMMANDS.PUSH); + assert.ok(true, `${COMMANDS.PUSH} completed without error`); } catch (err: any) { assert.ok( err.message.includes("remote") || err.message.includes("origin"), - `gops.push failed with unexpected error: ${err.message}`, + `${COMMANDS.PUSH} failed with unexpected error: ${err.message}`, ); } }); diff --git a/test/integration/commit.test.ts b/test/integration/commit.test.ts index e9ab734..08111f0 100644 --- a/test/integration/commit.test.ts +++ b/test/integration/commit.test.ts @@ -1,5 +1,6 @@ import * as assert from "node:assert"; import * as vscode from "vscode"; +import { COMMANDS } from "../../src/commands/Commands"; suite("Commit", function () { this.timeout(30000); @@ -11,20 +12,20 @@ suite("Commit", function () { } }); - test("gops.commit should execute without error when no files staged", async function () { + test(`${COMMANDS.COMMIT} should execute without error when no files staged`, async function () { // Mock the input box to return undefined (simulating cancel) const stub = vscode.window.showInputBox; (vscode.window as any).showInputBox = async () => undefined; - await vscode.commands.executeCommand("gops.commit"); + await vscode.commands.executeCommand(COMMANDS.COMMIT); (vscode.window as any).showInputBox = stub; - assert.ok(true, "gops.commit completed without error"); + assert.ok(true, `${COMMANDS.COMMIT} completed without error`); }); - test("gops.refresh should complete after commit attempt", async function () { - await vscode.commands.executeCommand("gops.refresh"); + test(`${COMMANDS.REFRESH} should complete after commit attempt`, async function () { + await vscode.commands.executeCommand(COMMANDS.REFRESH); await new Promise((resolve) => setTimeout(resolve, 500)); - assert.ok(true, "gops.refresh completed after commit attempt"); + assert.ok(true, `${COMMANDS.REFRESH} completed after commit attempt`); }); }); diff --git a/test/integration/extension.test.ts b/test/integration/extension.test.ts index 93793a7..7eaf69e 100644 --- a/test/integration/extension.test.ts +++ b/test/integration/extension.test.ts @@ -1,5 +1,6 @@ import * as assert from "node:assert"; import * as vscode from "vscode"; +import { COMMANDS } from "../../src/commands/Commands"; suite("Extension", function () { this.timeout(30000); @@ -19,8 +20,8 @@ suite("Extension", function () { assert.strictEqual(extension.isActive, true, "Extension must be active"); - await vscode.commands.executeCommand("gops.refresh"); - assert.ok(true, "gops.refresh must complete without error"); + await vscode.commands.executeCommand(COMMANDS.REFRESH); + assert.ok(true, `${COMMANDS.REFRESH} must complete without error`); }); test("should register the Git Ops tree view", async function () { diff --git a/test/integration/stageFile.test.ts b/test/integration/stageFile.test.ts index 3dd8991..bb7ebf3 100644 --- a/test/integration/stageFile.test.ts +++ b/test/integration/stageFile.test.ts @@ -2,6 +2,7 @@ import * as assert from "node:assert"; import * as vscode from "vscode"; import * as path from "node:path"; import * as fs from "node:fs/promises"; +import { COMMANDS } from "../../src/commands/Commands"; suite("Stage/Unstage", function () { this.timeout(30000); @@ -30,14 +31,14 @@ suite("Stage/Unstage", function () { await new Promise((resolve) => setTimeout(resolve, 200)); }); - test("gops.unstageAllFiles should execute without error", async function () { - await vscode.commands.executeCommand("gops.unstageAllFiles"); - assert.ok(true, "gops.unstageAllFiles completed without error"); + test(`${COMMANDS.UNSTAGE_ALL_FILES} should execute without error`, async function () { + await vscode.commands.executeCommand(COMMANDS.UNSTAGE_ALL_FILES); + assert.ok(true, `${COMMANDS.UNSTAGE_ALL_FILES} completed without error`); }); - test("gops.refresh should reflect file changes", async function () { - await vscode.commands.executeCommand("gops.refresh"); + test(`${COMMANDS.REFRESH} should reflect file changes`, async function () { + await vscode.commands.executeCommand(COMMANDS.REFRESH); await new Promise((resolve) => setTimeout(resolve, 500)); - assert.ok(true, "gops.refresh completed after file change"); + assert.ok(true, `${COMMANDS.REFRESH} completed after file change`); }); });