From be0c2d9f7c7b4841f49522ad53eab3e6a10542fe Mon Sep 17 00:00:00 2001 From: CodeMan X Date: Fri, 22 May 2026 21:08:15 +1000 Subject: [PATCH 1/8] updated readme --- README.md | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/README.md b/README.md index 7d8db4f..ed8cfc3 100644 --- a/README.md +++ b/README.md @@ -44,17 +44,7 @@ Calling out known issues can help limit users opening duplicate issues against y ## Release Notes -Users appreciate release notes as you update your extension. - -### 0.0.1 - -Initial release of Gops - -## Following extension guidelines - -Ensure that you've read through the extensions guidelines and follow the best practices for creating your extension. - -- [Extension Guidelines](https://code.visualstudio.com/api/references/extension-guidelines) +Refer to CHANGELOG.md ## Build Process From 5fdf29f549b626cca89b6d6b6d8b90290a95c8cf Mon Sep 17 00:00:00 2001 From: CodeMan X Date: Mon, 25 May 2026 18:15:35 +1000 Subject: [PATCH 2/8] Updated --- src/commands/CommandRegistrar.ts | 129 ++++---------------------- src/commands/GitOperationsDelegate.ts | 85 +++++++++++++++++ src/extension.ts | 23 ++--- src/gopstree/TreeDataProvider.ts | 84 ++++++++++++++--- src/gopstree/nodes/RepositoryNode.ts | 12 ++- 5 files changed, 198 insertions(+), 135 deletions(-) create mode 100644 src/commands/GitOperationsDelegate.ts diff --git a/src/commands/CommandRegistrar.ts b/src/commands/CommandRegistrar.ts index a702303..5bd2905 100644 --- a/src/commands/CommandRegistrar.ts +++ b/src/commands/CommandRegistrar.ts @@ -1,122 +1,34 @@ import * as vscode from "vscode"; -import { TreeDataProvider } from "../gopstree/TreeDataProvider"; -import { GitService } from "../services/GitService"; import { COMMANDS } from "./Commands"; -import { GitTreeNode } from "../gopstree/types"; +import { GitOperationsDelegate } from "./GitOperationsDelegate"; import { Logger } from "../logging/Logger"; -import { DiffService } from "../services/DiffService"; -import { ChangedFileNode } from "../gopstree/nodes/ChangedFileNode"; export class CommandRegistrar { constructor( private context: vscode.ExtensionContext, - private treeDataProvider: TreeDataProvider, - private gitService: GitService, - private diffService: DiffService, + private delegate: GitOperationsDelegate, ) {} registerAll() { - this.register(COMMANDS.REFRESH, () => { - console.debug(`executed command: ${COMMANDS.REFRESH}`); - this.treeDataProvider.refresh(); - }); - - this.register(COMMANDS.CHECKOUT_BRANCH, async (node: GitTreeNode) => { - console.debug(`executed command: ${COMMANDS.CHECKOUT_BRANCH}`); - if (node && "branchName" in node) { - await this.gitService.checkout(node.branchName); - this.treeDataProvider.refresh(node.parent); - } - }); - - this.register(COMMANDS.DELETE_BRANCH, async (node: GitTreeNode) => { - console.debug(`executed command: ${COMMANDS.DELETE_BRANCH}`); - //this.gitService.deleteBranch(node); - }); - - this.register(COMMANDS.RENAME_BRANCH, async (node: GitTreeNode) => { - console.debug(`executed command: ${COMMANDS.RENAME_BRANCH}`); - //this.gitService.renameBranch(node); - }); - - this.register(COMMANDS.PUSH, async () => { - console.debug(`executed command: ${COMMANDS.PUSH}`); - await this.gitService.push(); - }); - - this.register(COMMANDS.PULL, async () => { - console.debug(`executed command: ${COMMANDS.PULL}`); - await this.gitService.pull(); - }); - - 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); - } - }, + this.register(COMMANDS.REFRESH, () => this.delegate.refresh()); + this.register(COMMANDS.CHECKOUT_BRANCH, (node) => + this.delegate.checkoutBranch(node), ); - - this.register(COMMANDS.SHOW_DIFF, async (node: GitTreeNode) => { - console.debug(`executed command: ${COMMANDS.SHOW_DIFF}`); - if (!node || !(node instanceof ChangedFileNode) || !node.fileName) { - return; - } - const repoPath = this.gitService.getRepoPath(); - - await this.diffService.openDiff({ - left: { - repositoryPath: repoPath, - fileName: node.fileName, - ref: "HEAD", - }, - right: { - repositoryPath: repoPath, - fileName: node.fileName, - }, - title: `Diff: ${node.fileName}`, - }); - }); + this.register(COMMANDS.DELETE_BRANCH, (node) => + this.delegate.deleteBranch(node), + ); + this.register(COMMANDS.RENAME_BRANCH, (node) => + this.delegate.renameBranch(node), + ); + this.register(COMMANDS.PUSH, () => this.delegate.push()); + this.register(COMMANDS.PULL, () => this.delegate.pull()); + this.register(COMMANDS.CREATE_BRANCH_FROM_CURRENT, (node) => + this.delegate.createBranchFromCurrent(node), + ); + this.register(COMMANDS.CREATE_BRANCH, (node) => + this.delegate.createBranchFrom(node), + ); + this.register(COMMANDS.SHOW_DIFF, (node) => this.delegate.showDiff(node)); } private register( @@ -135,7 +47,6 @@ export class CommandRegistrar { } }, ); - this.context.subscriptions.push(disposable); } } diff --git a/src/commands/GitOperationsDelegate.ts b/src/commands/GitOperationsDelegate.ts new file mode 100644 index 0000000..2fe2f3f --- /dev/null +++ b/src/commands/GitOperationsDelegate.ts @@ -0,0 +1,85 @@ +import * as vscode from "vscode"; +import { GitService } from "../services/GitService"; +import { DiffService } from "../services/DiffService"; +import { TreeDataProvider } from "../gopstree/TreeDataProvider"; +import { GitTreeNode } from "../gopstree/types"; +import { ChangedFileNode } from "../gopstree/nodes/ChangedFileNode"; + +export class GitOperationsDelegate { + constructor( + private readonly gitService: GitService, + private readonly diffService: DiffService, + private readonly treeDataProvider: TreeDataProvider, + private readonly treeView: vscode.TreeView, + ) {} + + refresh(): void { + this.treeDataProvider.refresh(); + } + + async checkoutBranch(node: GitTreeNode): Promise { + if (!node || !("branchName" in node)) {return;} + + await this.gitService.checkout(node.branchName); + await this.treeDataProvider.refreshRootNode(); + this.treeDataProvider.refreshLocalBranchesNode(); + } + + async deleteBranch(node: GitTreeNode): Promise { + if (!node || !("branchName" in node)) {return;} + // TODO: implement + } + + async renameBranch(node: GitTreeNode): Promise { + if (!node || !("branchName" in node)) {return;} + // TODO: implement + } + + async push(): Promise { + await this.gitService.push(); + } + + async pull(): Promise { + await this.gitService.pull(); + await this.treeDataProvider.refreshRootNode(); + this.treeDataProvider.refreshLocalBranchesNode(); + this.treeDataProvider.refreshRemoteBranchesNode(); + } + + async createBranchFromCurrent(node: GitTreeNode): Promise { + const branchName = await vscode.window.showInputBox({ + prompt: "Enter new branch name", + placeHolder: "feature/my-new-feature", + ignoreFocusOut: true, + }); + if (!branchName) {return;} + + await this.gitService.checkoutLocalBranch(branchName); + this.treeDataProvider.refreshLocalBranchesNode(); + } + + async createBranchFrom(node: GitTreeNode): Promise { + if (!node || !("branchName" in node)) {return;} + + const branchName = await vscode.window.showInputBox({ + prompt: `Enter new branch name to create from ${node.branchName}`, + placeHolder: "feature/my-new-feature", + ignoreFocusOut: true, + }); + if (!branchName) {return;} + + await this.gitService.checkoutBranch(branchName, node.branchName); + this.treeDataProvider.refreshLocalBranchesNode(); + } + + async showDiff(node: GitTreeNode): Promise { + if (!node || !(node instanceof ChangedFileNode) || !node.fileName) {return;} + + const repoPath = this.gitService.getRepoPath(); + await this.diffService.openDiff({ + left: { repositoryPath: repoPath, fileName: node.fileName, ref: "HEAD" }, + right: { repositoryPath: repoPath, fileName: node.fileName }, + title: `Diff: ${node.fileName}`, + }); + } +} diff --git a/src/extension.ts b/src/extension.ts index 5b904a8..9c8259e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,28 +1,29 @@ import * as vscode from "vscode"; -import { TreeDataProvider } from "./gopstree/TreeDataProvider"; -import { GitService } from "./services/GitService"; import { CommandRegistrar } from "./commands/CommandRegistrar"; -import { DiffService } from "./services/DiffService"; +import { GitOperationsDelegate } from "./commands/GitOperationsDelegate"; +import { GitService } from "./services/GitService"; import { FileService } from "./services/FileService"; +import { DiffService } from "./services/DiffService"; +import { TreeDataProvider } from "./gopstree/TreeDataProvider"; export function activate(context: vscode.ExtensionContext) { const gitService = new GitService(); const fileService = new FileService(context.globalStorageUri.fsPath); const diffService = new DiffService(fileService, gitService); const treeDataProvider = new TreeDataProvider(gitService); - const treeView = vscode.window.createTreeView("gitOpsTreeview", { treeDataProvider }); - - // Register commands - const registrar = new CommandRegistrar( - context, + const treeView = vscode.window.createTreeView("gitOpsTreeview", { treeDataProvider, + }); + + const delegate = new GitOperationsDelegate( gitService, diffService, + treeDataProvider, + treeView, ); + const registrar = new CommandRegistrar(context, delegate); registrar.registerAll(); context.subscriptions.push(treeView); - console.log("Gops extension activated."); + console.log("Gops extension activated."); } - -export function deactivate() {} diff --git a/src/gopstree/TreeDataProvider.ts b/src/gopstree/TreeDataProvider.ts index e896922..112be0d 100644 --- a/src/gopstree/TreeDataProvider.ts +++ b/src/gopstree/TreeDataProvider.ts @@ -16,6 +16,12 @@ export class TreeDataProvider implements vscode.TreeDataProvider { >(); readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + private rootNode: RepositoryNode | undefined; + private localBranchesNode: TreeItemModel | undefined; + private remoteBranchesNode: TreeItemModel | undefined; + private changesNode: TreeItemModel | undefined; + private tagsNode: TreeItemModel | undefined; + private stashNode: TreeItemModel | undefined; constructor(private readonly gitService: GitService) {} @@ -28,7 +34,8 @@ export class TreeDataProvider implements vscode.TreeDataProvider { if (!element) { const repoName = this.gitService.getRepoName(); const currentBranch = await this.gitService.getCurrentBranch(); - return [new RepositoryNode(repoName, currentBranch)]; + this.rootNode = new RepositoryNode(repoName, currentBranch); + return [this.rootNode]; } //Routing based on node type @@ -50,15 +57,15 @@ export class TreeDataProvider implements vscode.TreeDataProvider { } } - private async getLocalBranches(parent: TreeItemModel): Promise { + private async getLocalBranches( + parent: TreeItemModel, + ): Promise { const branches = await this.gitService.getLocalBranches(); - const allLocalBranches = branches.map( - (b) => { - const node = new LocalBranchNode(b.name, b.current, b.ahead, b.behind); - node.parent = parent; - return node; - } - ); + const allLocalBranches = branches.map((b) => { + const node = new LocalBranchNode(b.name, b.current, b.ahead, b.behind); + node.parent = parent; + return node; + }); for (const branch of allLocalBranches) { console.debug(branch.toString()); } @@ -87,18 +94,32 @@ export class TreeDataProvider implements vscode.TreeDataProvider { console.debug(node.toString()); return node; }); - + return allChangedFiles; } private async getTags(): Promise { const tags = await this.gitService.getTags(); - return tags.map((t) => new TreeItemModel({ label: t }, NodeType.Tags, vscode.TreeItemCollapsibleState.None)); + 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)); + return stash.map( + (s) => + new TreeItemModel( + { label: s }, + NodeType.Stash, + vscode.TreeItemCollapsibleState.None, + ), + ); } private getRepositoryChildren(): TreeItemModel[] { @@ -108,6 +129,7 @@ export class TreeDataProvider implements vscode.TreeDataProvider { vscode.TreeItemCollapsibleState.Collapsed, ); localBranchesItem.iconPath = new vscode.ThemeIcon("go-to-file"); + this.localBranchesNode = localBranchesItem; const remoteBranchesItem = new TreeItemModel( { label: Constants.REMOTE_BRANCHES_LABEL }, @@ -115,6 +137,7 @@ export class TreeDataProvider implements vscode.TreeDataProvider { vscode.TreeItemCollapsibleState.Collapsed, ); remoteBranchesItem.iconPath = new vscode.ThemeIcon("cloud"); + this.remoteBranchesNode = remoteBranchesItem; const changesItem = new TreeItemModel( { label: Constants.CHANGES_LABEL }, @@ -122,6 +145,7 @@ export class TreeDataProvider implements vscode.TreeDataProvider { vscode.TreeItemCollapsibleState.Collapsed, ); changesItem.iconPath = new vscode.ThemeIcon("diff"); + this.changesNode = changesItem; const tagsItem = new TreeItemModel( { label: Constants.TAGS_LABEL }, @@ -129,6 +153,7 @@ export class TreeDataProvider implements vscode.TreeDataProvider { vscode.TreeItemCollapsibleState.Collapsed, ); tagsItem.iconPath = new vscode.ThemeIcon("tag"); + this.tagsNode = tagsItem; const stashItem = new TreeItemModel( { label: Constants.STASH_LABEL }, @@ -136,8 +161,15 @@ export class TreeDataProvider implements vscode.TreeDataProvider { vscode.TreeItemCollapsibleState.Collapsed, ); stashItem.iconPath = new vscode.ThemeIcon("save"); - - return [localBranchesItem, remoteBranchesItem, changesItem, tagsItem, stashItem]; + this.stashNode = stashItem; + + return [ + localBranchesItem, + remoteBranchesItem, + changesItem, + tagsItem, + stashItem, + ]; } refresh(node?: GitTreeNode): void { @@ -148,4 +180,28 @@ export class TreeDataProvider implements vscode.TreeDataProvider { Notifications.info("Git Ops tree view refreshed"); } } + + async refreshRootNode(): Promise { + if (!this.rootNode) + {return;} + + const currentBranch = await this.gitService.getCurrentBranch(); + this.rootNode.updateActiveBranchLabel(currentBranch); + this._onDidChangeTreeData.fire(this.rootNode); + } + + refreshLocalBranchesNode(): void { + if (!this.localBranchesNode) {return;} + this._onDidChangeTreeData.fire(this.localBranchesNode); + } + + refreshRemoteBranchesNode(): void { + if (!this.remoteBranchesNode) {return;} + this._onDidChangeTreeData.fire(this.remoteBranchesNode); + } + + refreshChangesNode(): void { + if (!this.changesNode) {return;} + this._onDidChangeTreeData.fire(this.changesNode); + } } diff --git a/src/gopstree/nodes/RepositoryNode.ts b/src/gopstree/nodes/RepositoryNode.ts index f40a334..f7142ef 100644 --- a/src/gopstree/nodes/RepositoryNode.ts +++ b/src/gopstree/nodes/RepositoryNode.ts @@ -4,12 +4,22 @@ import { TreeItemModel } from "../TreeItemModel"; import * as vscode from 'vscode'; export class RepositoryNode extends TreeItemModel { + declare label: vscode.TreeItemLabel; + constructor( public readonly repoName: string, public readonly branch: string, ) { - super({ label: `${repoName} (${branch})` }, NodeType.Repository, vscode.TreeItemCollapsibleState.Expanded); + super( + { label: `${repoName} (${branch})` }, + NodeType.Repository, + vscode.TreeItemCollapsibleState.Expanded, + ); this.contextValue = ContextValue.Repository; this.iconPath = new vscode.ThemeIcon("repo"); } + + updateActiveBranchLabel(branchLabel: string): void { + this.label.label = `${this.repoName} (${branchLabel})`; + } } From 83a342a7d3375a529e293bd019cbed641785c55a Mon Sep 17 00:00:00 2001 From: CodeMan X Date: Mon, 25 May 2026 18:57:28 +1000 Subject: [PATCH 3/8] updated --- src/gopstree/TreeDataProvider.ts | 36 +++++++++++++++++++--------- src/gopstree/TreeItemModel.ts | 4 ++-- src/gopstree/nodes/RepositoryNode.ts | 10 ++++---- 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/src/gopstree/TreeDataProvider.ts b/src/gopstree/TreeDataProvider.ts index 112be0d..1ec8c89 100644 --- a/src/gopstree/TreeDataProvider.ts +++ b/src/gopstree/TreeDataProvider.ts @@ -126,7 +126,8 @@ export class TreeDataProvider implements vscode.TreeDataProvider { const localBranchesItem = new TreeItemModel( { label: Constants.LOCAL_BRANCHES_LABEL }, NodeType.Local, - vscode.TreeItemCollapsibleState.Collapsed, + this.localBranchesNode?.collapsibleState || + vscode.TreeItemCollapsibleState.Collapsed, ); localBranchesItem.iconPath = new vscode.ThemeIcon("go-to-file"); this.localBranchesNode = localBranchesItem; @@ -134,7 +135,8 @@ export class TreeDataProvider implements vscode.TreeDataProvider { const remoteBranchesItem = new TreeItemModel( { label: Constants.REMOTE_BRANCHES_LABEL }, NodeType.Remote, - vscode.TreeItemCollapsibleState.Collapsed, + this.remoteBranchesNode?.collapsibleState || + vscode.TreeItemCollapsibleState.Collapsed, ); remoteBranchesItem.iconPath = new vscode.ThemeIcon("cloud"); this.remoteBranchesNode = remoteBranchesItem; @@ -142,7 +144,8 @@ export class TreeDataProvider implements vscode.TreeDataProvider { const changesItem = new TreeItemModel( { label: Constants.CHANGES_LABEL }, NodeType.Changes, - vscode.TreeItemCollapsibleState.Collapsed, + this.changesNode?.collapsibleState || + vscode.TreeItemCollapsibleState.Collapsed, ); changesItem.iconPath = new vscode.ThemeIcon("diff"); this.changesNode = changesItem; @@ -150,7 +153,8 @@ export class TreeDataProvider implements vscode.TreeDataProvider { const tagsItem = new TreeItemModel( { label: Constants.TAGS_LABEL }, NodeType.Tags, - vscode.TreeItemCollapsibleState.Collapsed, + this.tagsNode?.collapsibleState || + vscode.TreeItemCollapsibleState.Collapsed, ); tagsItem.iconPath = new vscode.ThemeIcon("tag"); this.tagsNode = tagsItem; @@ -158,7 +162,8 @@ export class TreeDataProvider implements vscode.TreeDataProvider { const stashItem = new TreeItemModel( { label: Constants.STASH_LABEL }, NodeType.Stash, - vscode.TreeItemCollapsibleState.Collapsed, + this.stashNode?.collapsibleState || + vscode.TreeItemCollapsibleState.Collapsed, ); stashItem.iconPath = new vscode.ThemeIcon("save"); this.stashNode = stashItem; @@ -182,26 +187,35 @@ export class TreeDataProvider implements vscode.TreeDataProvider { } async refreshRootNode(): Promise { - if (!this.rootNode) - {return;} + if (!this.rootNode) { + return; + } const currentBranch = await this.gitService.getCurrentBranch(); this.rootNode.updateActiveBranchLabel(currentBranch); this._onDidChangeTreeData.fire(this.rootNode); } - refreshLocalBranchesNode(): void { - if (!this.localBranchesNode) {return;} + async refreshLocalBranchesNode(): Promise { + if (!this.localBranchesNode) { + return; + } + this.localBranchesNode.collapsibleState = + vscode.TreeItemCollapsibleState.Expanded; this._onDidChangeTreeData.fire(this.localBranchesNode); } refreshRemoteBranchesNode(): void { - if (!this.remoteBranchesNode) {return;} + if (!this.remoteBranchesNode) { + return; + } this._onDidChangeTreeData.fire(this.remoteBranchesNode); } refreshChangesNode(): void { - if (!this.changesNode) {return;} + if (!this.changesNode) { + return; + } this._onDidChangeTreeData.fire(this.changesNode); } } diff --git a/src/gopstree/TreeItemModel.ts b/src/gopstree/TreeItemModel.ts index bd337e5..004a357 100644 --- a/src/gopstree/TreeItemModel.ts +++ b/src/gopstree/TreeItemModel.ts @@ -5,9 +5,9 @@ export class TreeItemModel extends vscode.TreeIte readonly type: T; constructor( - public readonly label: vscode.TreeItemLabel, + public label: vscode.TreeItemLabel, type: T, - public readonly collapsibleState: vscode.TreeItemCollapsibleState, + public collapsibleState: vscode.TreeItemCollapsibleState, public readonly command?: vscode.Command, public readonly children: TreeItemModel[] = [], public parent?: TreeItemModel, diff --git a/src/gopstree/nodes/RepositoryNode.ts b/src/gopstree/nodes/RepositoryNode.ts index f7142ef..18f7499 100644 --- a/src/gopstree/nodes/RepositoryNode.ts +++ b/src/gopstree/nodes/RepositoryNode.ts @@ -1,14 +1,12 @@ import { ContextValue } from "../ContextValue"; import { NodeType } from "./NodeType"; import { TreeItemModel } from "../TreeItemModel"; -import * as vscode from 'vscode'; +import * as vscode from "vscode"; export class RepositoryNode extends TreeItemModel { - declare label: vscode.TreeItemLabel; - constructor( public readonly repoName: string, - public readonly branch: string, + branch: string, ) { super( { label: `${repoName} (${branch})` }, @@ -19,7 +17,7 @@ export class RepositoryNode extends TreeItemModel { this.iconPath = new vscode.ThemeIcon("repo"); } - updateActiveBranchLabel(branchLabel: string): void { - this.label.label = `${this.repoName} (${branchLabel})`; + updateActiveBranchLabel(branch: string): void { + this.label = { label: `${this.repoName} (${branch})` }; } } From 11c559d537f760b0fccef97f81a06375bf7eeaed Mon Sep 17 00:00:00 2001 From: CodeMan X Date: Tue, 26 May 2026 20:08:35 +1000 Subject: [PATCH 4/8] Added staged changes --- src/gopstree/ContextValue.ts | 1 + src/gopstree/TreeDataProvider.ts | 22 +++++++----- src/gopstree/nodes/NodeType.ts | 1 + src/gopstree/nodes/StagedChangesFileNode.ts | 37 +++++++++++++++++++++ src/services/GitService.ts | 16 +++++++++ 5 files changed, 68 insertions(+), 9 deletions(-) create mode 100644 src/gopstree/nodes/StagedChangesFileNode.ts diff --git a/src/gopstree/ContextValue.ts b/src/gopstree/ContextValue.ts index b584d9f..1a9d978 100644 --- a/src/gopstree/ContextValue.ts +++ b/src/gopstree/ContextValue.ts @@ -1,6 +1,7 @@ export enum ContextValue { Repository = "repository", Changes = "changes", + StagedChanges = "stagedChanges", LocalBranches = "localBranches", LocalBranchesCurrent = "localBranches.current", RemoteBranches = "remoteBranches", diff --git a/src/gopstree/TreeDataProvider.ts b/src/gopstree/TreeDataProvider.ts index 1ec8c89..6b0895b 100644 --- a/src/gopstree/TreeDataProvider.ts +++ b/src/gopstree/TreeDataProvider.ts @@ -9,6 +9,7 @@ import { Constants } from "../constants/Constants"; import { GitTreeNode } from "./types"; import { Notifications } from "../notifications/Notifications"; import { ChangedFileNode } from "./nodes/ChangedFileNode"; +import { StagedChangesFileNode } from "./nodes/StagedChangesFileNode"; export class TreeDataProvider implements vscode.TreeDataProvider { private _onDidChangeTreeData = new vscode.EventEmitter< @@ -48,6 +49,8 @@ export class TreeDataProvider implements vscode.TreeDataProvider { return this.getRemoteBranches(); case NodeType.Changes: return this.getChanges(); + case NodeType.StagedChanges: + return this.getStagedChanges(); case NodeType.Tags: return this.getTags(); case NodeType.Stash: @@ -80,15 +83,7 @@ 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), - ]; - + const changedFiles = await this.gitService.getChangedFiles(); const allChangedFiles = changedFiles.map((f) => { const node = new ChangedFileNode(f); console.debug(node.toString()); @@ -98,6 +93,15 @@ export class TreeDataProvider implements vscode.TreeDataProvider { return allChangedFiles; } + private async getStagedChanges(): Promise { + const stagedFiles = await this.gitService.getStagedFiles(); + return stagedFiles.map((f) => { + const node = new StagedChangesFileNode(f); + console.debug(node.toString()); + return node; + }); + } + private async getTags(): Promise { const tags = await this.gitService.getTags(); return tags.map( diff --git a/src/gopstree/nodes/NodeType.ts b/src/gopstree/nodes/NodeType.ts index 071c607..348a2de 100644 --- a/src/gopstree/nodes/NodeType.ts +++ b/src/gopstree/nodes/NodeType.ts @@ -6,6 +6,7 @@ export enum NodeType { Local = "local", Remote = "remote", Changes = "changes", + StagedChanges = "stagedChanges", Tags = "tags", Stash = "stash", diff --git a/src/gopstree/nodes/StagedChangesFileNode.ts b/src/gopstree/nodes/StagedChangesFileNode.ts new file mode 100644 index 0000000..50b18a8 --- /dev/null +++ b/src/gopstree/nodes/StagedChangesFileNode.ts @@ -0,0 +1,37 @@ +import { ContextValue } from "../ContextValue"; +import { NodeType } from "./NodeType"; +import { TreeItemModel } from "../TreeItemModel"; +import * as vscode from 'vscode'; +import { createChangedFileTooltip, formatChangedFileLabel } from "./utils/nodeUtils"; +import { COMMANDS } from "../../commands/Commands"; + +export class StagedChangesFileNode extends TreeItemModel { + public override command?: vscode.Command; + constructor( + public readonly fileName: string, + ) { + const fomatted = formatChangedFileLabel(fileName); + super( + { + label: fomatted.label, + highlights: fomatted.highlights, + }, + NodeType.StagedChanges, + vscode.TreeItemCollapsibleState.None, + ); + this.contextValue = ContextValue.StagedChanges; + this.command = { + title: "Show Diff", + command: COMMANDS.SHOW_DIFF, + arguments: [this], + }; + + 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 32f4f55..ec3c45c 100644 --- a/src/services/GitService.ts +++ b/src/services/GitService.ts @@ -28,6 +28,22 @@ export class GitService { return this.git.status(); } + async getChangedFiles(): Promise { + const status = await this.git.status(); + const allChangedFiles = [ + ...status.modified, + ...status.created, + ...status.deleted, + ...status.renamed.map((f) => f.to), + ]; + return allChangedFiles; + } + + async getStagedFiles(): Promise { + const status = await this.git.status(); + return status.staged; + } + async getBranches(): Promise { return this.git.branch(); } From fda2737b5db764f0ea827e2f487439c991650246 Mon Sep 17 00:00:00 2001 From: CodeMan X Date: Thu, 28 May 2026 19:30:13 +1000 Subject: [PATCH 5/8] Added inline button to stage changes within the changed file node --- package.json | 13 +++++++ src/commands/CommandRegistrar.ts | 1 + src/commands/Commands.ts | 1 + src/commands/GitOperationsDelegate.ts | 38 +++++++++++++++---- src/constants/Constants.ts | 1 + src/extension.ts | 13 ++++++- src/gopstree/ContextValue.ts | 4 +- src/gopstree/TreeDataProvider.ts | 22 ++++++++++- ...edChangesFileNode.ts => StagedFileNode.ts} | 2 +- src/services/GitService.ts | 22 +++++++---- 10 files changed, 96 insertions(+), 21 deletions(-) rename src/gopstree/nodes/{StagedChangesFileNode.ts => StagedFileNode.ts} (92%) diff --git a/package.json b/package.json index 80b992b..c3d9847 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,11 @@ "command": "gops.showDiff", "title": "Show Diff", "icon": "$(diff)" + }, + { + "command": "gops.stageFile", + "title": "Stage File", + "icon": "$(add)" } ], "viewsContainers": { @@ -143,13 +148,21 @@ "view/item/context": [ { "command": "gops.branch", + "title": "New Branch", "when": "view == gitOpsTreeview && (viewItem == localBranches || viewItem == localBranches.current)", "group": "navigation" }, { "command": "gops.checkout", + "title": "Checkout Branch", "when": "view == gitOpsTreeview && viewItem == localBranches", "group": "navigation" + }, + { + "command": "gops.stageFile", + "title": "Stage File", + "when": "view == gitOpsTreeview && viewItem == changedFile", + "group": "inline" } ] } diff --git a/src/commands/CommandRegistrar.ts b/src/commands/CommandRegistrar.ts index 5bd2905..089ec0d 100644 --- a/src/commands/CommandRegistrar.ts +++ b/src/commands/CommandRegistrar.ts @@ -29,6 +29,7 @@ export class CommandRegistrar { this.delegate.createBranchFrom(node), ); this.register(COMMANDS.SHOW_DIFF, (node) => this.delegate.showDiff(node)); + this.register(COMMANDS.STAGE_FILE, (node) => this.delegate.stageFile(node)); } private register( diff --git a/src/commands/Commands.ts b/src/commands/Commands.ts index d97f9d5..d5ec431 100644 --- a/src/commands/Commands.ts +++ b/src/commands/Commands.ts @@ -9,4 +9,5 @@ export const COMMANDS = { CREATE_BRANCH: "gops.branch", CREATE_TAG: "gops.tag", SHOW_DIFF: "gops.showDiff", + STAGE_FILE: "gops.stageFile", } as const; diff --git a/src/commands/GitOperationsDelegate.ts b/src/commands/GitOperationsDelegate.ts index 2fe2f3f..3a30644 100644 --- a/src/commands/GitOperationsDelegate.ts +++ b/src/commands/GitOperationsDelegate.ts @@ -18,7 +18,9 @@ export class GitOperationsDelegate { } async checkoutBranch(node: GitTreeNode): Promise { - if (!node || !("branchName" in node)) {return;} + if (!node || !("branchName" in node)) { + return; + } await this.gitService.checkout(node.branchName); await this.treeDataProvider.refreshRootNode(); @@ -26,12 +28,16 @@ export class GitOperationsDelegate { } async deleteBranch(node: GitTreeNode): Promise { - if (!node || !("branchName" in node)) {return;} + if (!node || !("branchName" in node)) { + return; + } // TODO: implement } async renameBranch(node: GitTreeNode): Promise { - if (!node || !("branchName" in node)) {return;} + if (!node || !("branchName" in node)) { + return; + } // TODO: implement } @@ -52,28 +58,36 @@ export class GitOperationsDelegate { placeHolder: "feature/my-new-feature", ignoreFocusOut: true, }); - if (!branchName) {return;} + if (!branchName) { + return; + } await this.gitService.checkoutLocalBranch(branchName); this.treeDataProvider.refreshLocalBranchesNode(); } async createBranchFrom(node: GitTreeNode): Promise { - if (!node || !("branchName" in node)) {return;} + if (!node || !("branchName" in node)) { + return; + } const branchName = await vscode.window.showInputBox({ prompt: `Enter new branch name to create from ${node.branchName}`, placeHolder: "feature/my-new-feature", ignoreFocusOut: true, }); - if (!branchName) {return;} + if (!branchName) { + return; + } await this.gitService.checkoutBranch(branchName, node.branchName); this.treeDataProvider.refreshLocalBranchesNode(); } async showDiff(node: GitTreeNode): Promise { - if (!node || !(node instanceof ChangedFileNode) || !node.fileName) {return;} + if (!node || !(node instanceof ChangedFileNode) || !node.fileName) { + return; + } const repoPath = this.gitService.getRepoPath(); await this.diffService.openDiff({ @@ -82,4 +96,14 @@ export class GitOperationsDelegate { title: `Diff: ${node.fileName}`, }); } + + async stageFile(node: GitTreeNode): Promise { + if (!node || !(node instanceof ChangedFileNode) || !node.fileName) { + return; + } + + await this.gitService.stageFile(node.fileName); + this.treeDataProvider.refreshChangesNode(); + this.treeDataProvider.refreshStagedNode(); + } } diff --git a/src/constants/Constants.ts b/src/constants/Constants.ts index f3806d5..f6bd920 100644 --- a/src/constants/Constants.ts +++ b/src/constants/Constants.ts @@ -3,6 +3,7 @@ export class Constants { static readonly LOCAL_BRANCHES_LABEL = 'Local Branches'; static readonly REMOTE_BRANCHES_LABEL = 'Remote Branches'; static readonly CHANGES_LABEL = 'Changes'; + static readonly STAGED_LABEL = 'Staged Changes'; static readonly TAGS_LABEL = 'Tags'; static readonly STASH_LABEL = 'Stash'; diff --git a/src/extension.ts b/src/extension.ts index 9c8259e..015459e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -24,6 +24,17 @@ export function activate(context: vscode.ExtensionContext) { const registrar = new CommandRegistrar(context, delegate); registrar.registerAll(); - context.subscriptions.push(treeView); + const onSave = vscode.workspace.onDidSaveTextDocument(() => { + treeDataProvider.refreshChangesNode(); + treeDataProvider.refreshStagedNode(); + }); + + const gitWatcher = vscode.workspace.createFileSystemWatcher("**/.git/index"); + gitWatcher.onDidChange(() => { + treeDataProvider.refreshChangesNode(); + treeDataProvider.refreshStagedNode(); + }); + + context.subscriptions.push(treeView, onSave, gitWatcher); console.log("Gops extension activated."); } diff --git a/src/gopstree/ContextValue.ts b/src/gopstree/ContextValue.ts index 1a9d978..1fa9412 100644 --- a/src/gopstree/ContextValue.ts +++ b/src/gopstree/ContextValue.ts @@ -1,7 +1,7 @@ export enum ContextValue { Repository = "repository", - Changes = "changes", - StagedChanges = "stagedChanges", + Changes = "changedFile", + StagedChanges = "stagedFile", LocalBranches = "localBranches", LocalBranchesCurrent = "localBranches.current", RemoteBranches = "remoteBranches", diff --git a/src/gopstree/TreeDataProvider.ts b/src/gopstree/TreeDataProvider.ts index 6b0895b..c8ee1ab 100644 --- a/src/gopstree/TreeDataProvider.ts +++ b/src/gopstree/TreeDataProvider.ts @@ -9,7 +9,7 @@ import { Constants } from "../constants/Constants"; import { GitTreeNode } from "./types"; import { Notifications } from "../notifications/Notifications"; import { ChangedFileNode } from "./nodes/ChangedFileNode"; -import { StagedChangesFileNode } from "./nodes/StagedChangesFileNode"; +import { StagedFileNode } from "./nodes/StagedFileNode"; export class TreeDataProvider implements vscode.TreeDataProvider { private _onDidChangeTreeData = new vscode.EventEmitter< @@ -21,6 +21,7 @@ export class TreeDataProvider implements vscode.TreeDataProvider { private localBranchesNode: TreeItemModel | undefined; private remoteBranchesNode: TreeItemModel | undefined; private changesNode: TreeItemModel | undefined; + private stagedNode: TreeItemModel | undefined; private tagsNode: TreeItemModel | undefined; private stashNode: TreeItemModel | undefined; @@ -96,7 +97,7 @@ export class TreeDataProvider implements vscode.TreeDataProvider { private async getStagedChanges(): Promise { const stagedFiles = await this.gitService.getStagedFiles(); return stagedFiles.map((f) => { - const node = new StagedChangesFileNode(f); + const node = new StagedFileNode(f); console.debug(node.toString()); return node; }); @@ -154,6 +155,15 @@ export class TreeDataProvider implements vscode.TreeDataProvider { changesItem.iconPath = new vscode.ThemeIcon("diff"); this.changesNode = changesItem; + const stagedItem = new TreeItemModel( + { label: Constants.STAGED_LABEL }, + NodeType.StagedChanges, + this.stagedNode?.collapsibleState || + vscode.TreeItemCollapsibleState.Collapsed, + ); + stagedItem.iconPath = new vscode.ThemeIcon("diff-added"); + this.stagedNode = stagedItem; + const tagsItem = new TreeItemModel( { label: Constants.TAGS_LABEL }, NodeType.Tags, @@ -176,6 +186,7 @@ export class TreeDataProvider implements vscode.TreeDataProvider { localBranchesItem, remoteBranchesItem, changesItem, + stagedItem, tagsItem, stashItem, ]; @@ -222,4 +233,11 @@ export class TreeDataProvider implements vscode.TreeDataProvider { } this._onDidChangeTreeData.fire(this.changesNode); } + + refreshStagedNode(): void { + if (!this.stagedNode) { + return; + } + this._onDidChangeTreeData.fire(this.stagedNode); + } } diff --git a/src/gopstree/nodes/StagedChangesFileNode.ts b/src/gopstree/nodes/StagedFileNode.ts similarity index 92% rename from src/gopstree/nodes/StagedChangesFileNode.ts rename to src/gopstree/nodes/StagedFileNode.ts index 50b18a8..24cd3d0 100644 --- a/src/gopstree/nodes/StagedChangesFileNode.ts +++ b/src/gopstree/nodes/StagedFileNode.ts @@ -5,7 +5,7 @@ import * as vscode from 'vscode'; import { createChangedFileTooltip, formatChangedFileLabel } from "./utils/nodeUtils"; import { COMMANDS } from "../../commands/Commands"; -export class StagedChangesFileNode extends TreeItemModel { +export class StagedFileNode extends TreeItemModel { public override command?: vscode.Command; constructor( public readonly fileName: string, diff --git a/src/services/GitService.ts b/src/services/GitService.ts index ec3c45c..14231e9 100644 --- a/src/services/GitService.ts +++ b/src/services/GitService.ts @@ -30,18 +30,24 @@ export class GitService { async getChangedFiles(): Promise { const status = await this.git.status(); - const allChangedFiles = [ - ...status.modified, - ...status.created, - ...status.deleted, - ...status.renamed.map((f) => f.to), - ]; - return allChangedFiles; + return status.files + .filter((f) => f.working_dir !== " " && f.working_dir !== "?") + .map((f) => f.path); } async getStagedFiles(): Promise { const status = await this.git.status(); - return status.staged; + return status.files + .filter((f) => f.index !== " " && f.index !== "?") + .map((f) => f.path); + } + + async stageFile(filePath: string): Promise { + await this.executeGitAction( + () => this.git.add(filePath), + `Staged file ${filePath} successfully`, + `Failed to stage file ${filePath}`, + ); } async getBranches(): Promise { From 75a99e212e758c419b93499a0272c1e62791d0ee Mon Sep 17 00:00:00 2001 From: CodeMan X Date: Fri, 29 May 2026 20:09:09 +1000 Subject: [PATCH 6/8] updated to track staged changes and commit them --- package.json | 21 ++++++++++++++++++++ src/commands/CommandRegistrar.ts | 5 +++++ src/commands/Commands.ts | 2 ++ src/commands/GitOperationsDelegate.ts | 28 ++++++++++++++++++++++++++- src/extension.ts | 9 +++++++++ src/gopstree/TreeDataProvider.ts | 10 ++++++++-- src/services/GitService.ts | 22 +++++++++++++++++++-- 7 files changed, 92 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index c3d9847..2de447d 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,16 @@ "command": "gops.stageFile", "title": "Stage File", "icon": "$(add)" + }, + { + "command": "gops.unstageFile", + "title": "Unstage File", + "icon": "$(remove)" + }, + { + "command": "gops.commit", + "title": "Commit", + "icon": "$(check)" } ], "viewsContainers": { @@ -124,6 +134,11 @@ }, "menus": { "view/title": [ + { + "command": "gops.commit", + "when": "view == gitOpsTreeview && gops.hasStagedFiles == true", + "group": "navigation" + }, { "command": "gops.refresh", "when": "view == gitOpsTreeview", @@ -163,6 +178,12 @@ "title": "Stage File", "when": "view == gitOpsTreeview && viewItem == changedFile", "group": "inline" + }, + { + "command": "gops.unstageFile", + "title": "Unstage File", + "when": "view == gitOpsTreeview && viewItem == stagedFile", + "group": "inline" } ] } diff --git a/src/commands/CommandRegistrar.ts b/src/commands/CommandRegistrar.ts index 089ec0d..a334fc4 100644 --- a/src/commands/CommandRegistrar.ts +++ b/src/commands/CommandRegistrar.ts @@ -10,6 +10,7 @@ export class CommandRegistrar { ) {} registerAll() { + vscode.commands.executeCommand("setContext", "gops.hasStagedFiles", false); this.register(COMMANDS.REFRESH, () => this.delegate.refresh()); this.register(COMMANDS.CHECKOUT_BRANCH, (node) => this.delegate.checkoutBranch(node), @@ -30,6 +31,10 @@ export class CommandRegistrar { ); this.register(COMMANDS.SHOW_DIFF, (node) => this.delegate.showDiff(node)); this.register(COMMANDS.STAGE_FILE, (node) => this.delegate.stageFile(node)); + this.register(COMMANDS.UNSTAGE_FILE, (node) => + this.delegate.unstageFile(node), + ); + this.register(COMMANDS.COMMIT, () => this.delegate.commit()); } private register( diff --git a/src/commands/Commands.ts b/src/commands/Commands.ts index d5ec431..bd86df1 100644 --- a/src/commands/Commands.ts +++ b/src/commands/Commands.ts @@ -10,4 +10,6 @@ export const COMMANDS = { CREATE_TAG: "gops.tag", SHOW_DIFF: "gops.showDiff", STAGE_FILE: "gops.stageFile", + UNSTAGE_FILE: "gops.unstageFile", + COMMIT: "gops.commit", } as const; diff --git a/src/commands/GitOperationsDelegate.ts b/src/commands/GitOperationsDelegate.ts index 3a30644..a4d398f 100644 --- a/src/commands/GitOperationsDelegate.ts +++ b/src/commands/GitOperationsDelegate.ts @@ -4,6 +4,7 @@ import { DiffService } from "../services/DiffService"; import { TreeDataProvider } from "../gopstree/TreeDataProvider"; import { GitTreeNode } from "../gopstree/types"; import { ChangedFileNode } from "../gopstree/nodes/ChangedFileNode"; +import { StagedFileNode } from "../gopstree/nodes/StagedFileNode"; export class GitOperationsDelegate { constructor( @@ -103,7 +104,32 @@ export class GitOperationsDelegate { } await this.gitService.stageFile(node.fileName); + await this.treeDataProvider.refreshChangesNode(); + await this.treeDataProvider.refreshStagedNode(); + } + + async unstageFile(node: GitTreeNode): Promise { + if (!node || !(node instanceof StagedFileNode) || !node.fileName) { + return; + } + + await this.gitService.unstageFile(node.fileName); + await this.treeDataProvider.refreshChangesNode(); + await this.treeDataProvider.refreshStagedNode(); + } + + async commit(): Promise { + const message = await vscode.window.showInputBox({ + prompt: "Enter commit message", + placeHolder: "feat: my changes", + ignoreFocusOut: true, + }); + if (!message) { + return; + } + + await this.gitService.commit(message); this.treeDataProvider.refreshChangesNode(); - this.treeDataProvider.refreshStagedNode(); + await this.treeDataProvider.refreshStagedNode(); } } diff --git a/src/extension.ts b/src/extension.ts index 015459e..0209443 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -35,6 +35,15 @@ export function activate(context: vscode.ExtensionContext) { treeDataProvider.refreshStagedNode(); }); + // Set initial context + gitService.getStagedFiles().then((files) => { + vscode.commands.executeCommand( + "setContext", + "gops.hasStagedFiles", + files.length > 0, + ); + }); + context.subscriptions.push(treeView, onSave, gitWatcher); console.log("Gops extension activated."); } diff --git a/src/gopstree/TreeDataProvider.ts b/src/gopstree/TreeDataProvider.ts index c8ee1ab..cec4eda 100644 --- a/src/gopstree/TreeDataProvider.ts +++ b/src/gopstree/TreeDataProvider.ts @@ -227,17 +227,23 @@ export class TreeDataProvider implements vscode.TreeDataProvider { this._onDidChangeTreeData.fire(this.remoteBranchesNode); } - refreshChangesNode(): void { + async refreshChangesNode(): Promise { if (!this.changesNode) { return; } this._onDidChangeTreeData.fire(this.changesNode); } - refreshStagedNode(): void { + async refreshStagedNode(): Promise { if (!this.stagedNode) { return; } + const stagedFiles = await this.gitService.getStagedFiles(); + await vscode.commands.executeCommand( + "setContext", + "gops.hasStagedFiles", + stagedFiles.length > 0, + ); this._onDidChangeTreeData.fire(this.stagedNode); } } diff --git a/src/services/GitService.ts b/src/services/GitService.ts index 14231e9..5689a58 100644 --- a/src/services/GitService.ts +++ b/src/services/GitService.ts @@ -1,4 +1,11 @@ -import simpleGit, { BranchSummary, DefaultLogFields, ListLogLine, RemoteWithoutRefs, SimpleGit, StatusResult } 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"; @@ -50,6 +57,14 @@ export class GitService { ); } + async unstageFile(filePath: string): Promise { + await this.executeGitAction( + () => this.git.reset(["HEAD", filePath]), + `Unstaged file ${filePath} successfully`, + `Failed to unstage file ${filePath}`, + ); + } + async getBranches(): Promise { return this.git.branch(); } @@ -127,8 +142,11 @@ export class GitService { } async commit(message: string) { + const status = await this.git.status(); + Logger.info(`Staged files before commit: ${JSON.stringify(status.staged)}`); + Logger.info(`All files: ${JSON.stringify(status.files)}`); return this.executeGitAction( - () => this.git.commit(message), + () => this.git.commit(message, []), "Commit successful", "Commit failed", ); From 0a6124dc6f6d565f9b01c35cc3b23116afc5bce6 Mon Sep 17 00:00:00 2001 From: CodeMan X Date: Sat, 30 May 2026 20:41:16 +1000 Subject: [PATCH 7/8] feat(ui): refactor tree view nodes and add unstage all functionality Refactor the GitOps tree view by introducing dedicated section nodes for local branches, remote branches, changes, staged changes, tags, and stashes. This improves code organization and type safety within the TreeDataProvider. Additionally, implements the "Unstage All Files" command, allowing users to reset all staged changes via the UI. - Add `unstageAllFiles` command and implementation in `GitService` and `GitOperationsDelegate`. - Introduce new section-specific node classes in `src/gopstree/nodes/`. - Update `TreeDataProvider` to use async methods for fetching repository children. - Add "Unstage All Files" inline button to the staged changes section. - Update unit tests to include mocks for new git operations. --- package.json | 13 +++- src/commands/CommandRegistrar.ts | 4 +- src/commands/Commands.ts | 1 + src/commands/GitOperationsDelegate.ts | 6 ++ src/extension.ts | 11 +-- src/gopstree/ContextValue.ts | 7 ++ src/gopstree/TreeDataProvider.ts | 79 +++++++++++---------- src/gopstree/nodes/ChangesSection.ts | 17 +++++ src/gopstree/nodes/LocalBranchesSection.ts | 17 +++++ src/gopstree/nodes/RemoteBranchesSection.ts | 17 +++++ src/gopstree/nodes/StagedChangesSection.ts | 17 +++++ src/gopstree/nodes/StashSection.ts | 13 ++++ src/gopstree/nodes/TagsSection.ts | 13 ++++ src/services/GitService.ts | 8 +++ test/unit/services/GitService.test.ts | 38 +++++++--- 15 files changed, 201 insertions(+), 60 deletions(-) create mode 100644 src/gopstree/nodes/ChangesSection.ts create mode 100644 src/gopstree/nodes/LocalBranchesSection.ts create mode 100644 src/gopstree/nodes/RemoteBranchesSection.ts create mode 100644 src/gopstree/nodes/StagedChangesSection.ts create mode 100644 src/gopstree/nodes/StashSection.ts create mode 100644 src/gopstree/nodes/TagsSection.ts diff --git a/package.json b/package.json index 2de447d..ba969a4 100644 --- a/package.json +++ b/package.json @@ -111,7 +111,12 @@ { "command": "gops.commit", "title": "Commit", - "icon": "$(check)" + "icon": "$(save)" + }, + { + "command": "gops.unstageAllFiles", + "title": "Unstage All Files", + "icon": "$(collapse-all)" } ], "viewsContainers": { @@ -184,6 +189,12 @@ "title": "Unstage File", "when": "view == gitOpsTreeview && viewItem == stagedFile", "group": "inline" + }, + { + "command": "gops.unstageAllFiles", + "title": "Unstage All Files", + "when": "view == gitOpsTreeview && viewItem == stagedChangesSection", + "group": "inline" } ] } diff --git a/src/commands/CommandRegistrar.ts b/src/commands/CommandRegistrar.ts index a334fc4..c427b0d 100644 --- a/src/commands/CommandRegistrar.ts +++ b/src/commands/CommandRegistrar.ts @@ -10,7 +10,6 @@ export class CommandRegistrar { ) {} registerAll() { - vscode.commands.executeCommand("setContext", "gops.hasStagedFiles", false); this.register(COMMANDS.REFRESH, () => this.delegate.refresh()); this.register(COMMANDS.CHECKOUT_BRANCH, (node) => this.delegate.checkoutBranch(node), @@ -34,6 +33,9 @@ export class CommandRegistrar { this.register(COMMANDS.UNSTAGE_FILE, (node) => this.delegate.unstageFile(node), ); + this.register(COMMANDS.UNSTAGE_ALL_FILES, () => + this.delegate.unstageAllFiles(), + ); this.register(COMMANDS.COMMIT, () => this.delegate.commit()); } diff --git a/src/commands/Commands.ts b/src/commands/Commands.ts index bd86df1..986fa62 100644 --- a/src/commands/Commands.ts +++ b/src/commands/Commands.ts @@ -11,5 +11,6 @@ export const COMMANDS = { SHOW_DIFF: "gops.showDiff", STAGE_FILE: "gops.stageFile", UNSTAGE_FILE: "gops.unstageFile", + UNSTAGE_ALL_FILES: "gops.unstageAllFiles", COMMIT: "gops.commit", } as const; diff --git a/src/commands/GitOperationsDelegate.ts b/src/commands/GitOperationsDelegate.ts index a4d398f..88af537 100644 --- a/src/commands/GitOperationsDelegate.ts +++ b/src/commands/GitOperationsDelegate.ts @@ -118,6 +118,12 @@ export class GitOperationsDelegate { await this.treeDataProvider.refreshStagedNode(); } + async unstageAllFiles(): Promise { + await this.gitService.unstageAllFiles(); + 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/extension.ts b/src/extension.ts index 0209443..02f3bd1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -34,16 +34,7 @@ export function activate(context: vscode.ExtensionContext) { treeDataProvider.refreshChangesNode(); treeDataProvider.refreshStagedNode(); }); - - // Set initial context - gitService.getStagedFiles().then((files) => { - vscode.commands.executeCommand( - "setContext", - "gops.hasStagedFiles", - files.length > 0, - ); - }); - + context.subscriptions.push(treeView, onSave, gitWatcher); console.log("Gops extension activated."); } diff --git a/src/gopstree/ContextValue.ts b/src/gopstree/ContextValue.ts index 1fa9412..87ba805 100644 --- a/src/gopstree/ContextValue.ts +++ b/src/gopstree/ContextValue.ts @@ -9,4 +9,11 @@ export enum ContextValue { File = "file", Stash = "stash", Commit = "commit", + LocalBranchesSection = "localBranchesSection", + RemoteBranchesSection = "remoteBranchesSection", + ChangesSection = "changesSection", + StagedChangesSection = "stagedChangesSection", + StagedChangesSectionEmpty = "stagedChangesSectionEmpty", + TagsSection = "tagsSection", + StashSection = "stashSection", } diff --git a/src/gopstree/TreeDataProvider.ts b/src/gopstree/TreeDataProvider.ts index cec4eda..63b7b19 100644 --- a/src/gopstree/TreeDataProvider.ts +++ b/src/gopstree/TreeDataProvider.ts @@ -10,6 +10,13 @@ import { GitTreeNode } from "./types"; import { Notifications } from "../notifications/Notifications"; import { ChangedFileNode } from "./nodes/ChangedFileNode"; import { StagedFileNode } from "./nodes/StagedFileNode"; +import { LocalBranchesSection } from "./nodes/LocalBranchesSection"; +import { RemoteBranchesSection } from "./nodes/RemoteBranchesSection"; +import { ChangesSection } from "./nodes/ChangesSection"; +import { StagedChangesSection } from "./nodes/StagedChangesSection"; +import { TagsSection } from "./nodes/TagsSection"; +import { StashSection } from "./nodes/StashSection"; +import { ContextValue } from "./ContextValue"; export class TreeDataProvider implements vscode.TreeDataProvider { private _onDidChangeTreeData = new vscode.EventEmitter< @@ -18,12 +25,12 @@ export class TreeDataProvider implements vscode.TreeDataProvider { readonly onDidChangeTreeData = this._onDidChangeTreeData.event; private rootNode: RepositoryNode | undefined; - private localBranchesNode: TreeItemModel | undefined; - private remoteBranchesNode: TreeItemModel | undefined; - private changesNode: TreeItemModel | undefined; - private stagedNode: TreeItemModel | undefined; - private tagsNode: TreeItemModel | undefined; - private stashNode: TreeItemModel | undefined; + private localBranchesNode: LocalBranchesSection | undefined; + private remoteBranchesNode: RemoteBranchesSection | undefined; + private changesNode: ChangesSection | undefined; + private stagedNode: StagedChangesSection | undefined; + private tagsNode: TagsSection | undefined; + private stashNode: StashSection | undefined; constructor(private readonly gitService: GitService) {} @@ -43,19 +50,19 @@ export class TreeDataProvider implements vscode.TreeDataProvider { //Routing based on node type switch (element.type) { case NodeType.Repository: - return this.getRepositoryChildren(); + return await this.getRepositoryChildren(); case NodeType.Local: - return this.getLocalBranches(element); + return await this.getLocalBranches(element); case NodeType.Remote: - return this.getRemoteBranches(); + return await this.getRemoteBranches(); case NodeType.Changes: - return this.getChanges(); + return await this.getChanges(); case NodeType.StagedChanges: - return this.getStagedChanges(); + return await this.getStagedChanges(); case NodeType.Tags: - return this.getTags(); + return await this.getTags(); case NodeType.Stash: - return this.getStash(); + return await this.getStash(); default: return []; } @@ -127,59 +134,49 @@ export class TreeDataProvider implements vscode.TreeDataProvider { ); } - private getRepositoryChildren(): TreeItemModel[] { - const localBranchesItem = new TreeItemModel( - { label: Constants.LOCAL_BRANCHES_LABEL }, - NodeType.Local, + private async getRepositoryChildren(): Promise { + const stagedFiles = await this.gitService.getStagedFiles(); + const hasStagedFiles = stagedFiles.length > 0; + await vscode.commands.executeCommand( + "setContext", + "gops.hasStagedFiles", + hasStagedFiles, + ); + + const localBranchesItem = new LocalBranchesSection( this.localBranchesNode?.collapsibleState || vscode.TreeItemCollapsibleState.Collapsed, ); - localBranchesItem.iconPath = new vscode.ThemeIcon("go-to-file"); this.localBranchesNode = localBranchesItem; - const remoteBranchesItem = new TreeItemModel( - { label: Constants.REMOTE_BRANCHES_LABEL }, - NodeType.Remote, + const remoteBranchesItem = new RemoteBranchesSection( this.remoteBranchesNode?.collapsibleState || vscode.TreeItemCollapsibleState.Collapsed, ); - remoteBranchesItem.iconPath = new vscode.ThemeIcon("cloud"); this.remoteBranchesNode = remoteBranchesItem; - const changesItem = new TreeItemModel( - { label: Constants.CHANGES_LABEL }, - NodeType.Changes, + const changesItem = new ChangesSection( this.changesNode?.collapsibleState || vscode.TreeItemCollapsibleState.Collapsed, ); - changesItem.iconPath = new vscode.ThemeIcon("diff"); this.changesNode = changesItem; - const stagedItem = new TreeItemModel( - { label: Constants.STAGED_LABEL }, - NodeType.StagedChanges, + const stagedItem = new StagedChangesSection( this.stagedNode?.collapsibleState || vscode.TreeItemCollapsibleState.Collapsed, ); - stagedItem.iconPath = new vscode.ThemeIcon("diff-added"); this.stagedNode = stagedItem; - const tagsItem = new TreeItemModel( - { label: Constants.TAGS_LABEL }, - NodeType.Tags, + const tagsItem = new TagsSection( this.tagsNode?.collapsibleState || vscode.TreeItemCollapsibleState.Collapsed, ); - tagsItem.iconPath = new vscode.ThemeIcon("tag"); this.tagsNode = tagsItem; - const stashItem = new TreeItemModel( - { label: Constants.STASH_LABEL }, - NodeType.Stash, + const stashItem = new StashSection( this.stashNode?.collapsibleState || vscode.TreeItemCollapsibleState.Collapsed, ); - stashItem.iconPath = new vscode.ThemeIcon("save"); this.stashNode = stashItem; return [ @@ -239,11 +236,15 @@ export class TreeDataProvider implements vscode.TreeDataProvider { return; } const stagedFiles = await this.gitService.getStagedFiles(); + const hasStagedFiles = stagedFiles.length > 0; await vscode.commands.executeCommand( "setContext", "gops.hasStagedFiles", - stagedFiles.length > 0, + hasStagedFiles, ); + this.stagedNode.contextValue = hasStagedFiles + ? ContextValue.StagedChangesSection + : ContextValue.StagedChangesSectionEmpty; this._onDidChangeTreeData.fire(this.stagedNode); } } diff --git a/src/gopstree/nodes/ChangesSection.ts b/src/gopstree/nodes/ChangesSection.ts new file mode 100644 index 0000000..315a3bf --- /dev/null +++ b/src/gopstree/nodes/ChangesSection.ts @@ -0,0 +1,17 @@ +import { ContextValue } from "../ContextValue"; +import { NodeType } from "./NodeType"; +import { TreeItemModel } from "../TreeItemModel"; +import * as vscode from "vscode"; +import { Constants } from "../../constants/Constants"; + +export class ChangesSection extends TreeItemModel { + constructor(collapsibleState: vscode.TreeItemCollapsibleState) { + super( + { label: Constants.CHANGES_LABEL }, + NodeType.Changes, + collapsibleState, + ); + this.contextValue = ContextValue.ChangesSection; + this.iconPath = new vscode.ThemeIcon("diff"); + } +} diff --git a/src/gopstree/nodes/LocalBranchesSection.ts b/src/gopstree/nodes/LocalBranchesSection.ts new file mode 100644 index 0000000..9662716 --- /dev/null +++ b/src/gopstree/nodes/LocalBranchesSection.ts @@ -0,0 +1,17 @@ +import { ContextValue } from "../ContextValue"; +import { NodeType } from "./NodeType"; +import { TreeItemModel } from "../TreeItemModel"; +import * as vscode from "vscode"; +import { Constants } from "../../constants/Constants"; + +export class LocalBranchesSection extends TreeItemModel { + constructor(collapsibleState: vscode.TreeItemCollapsibleState) { + super( + { label: Constants.LOCAL_BRANCHES_LABEL }, + NodeType.Local, + collapsibleState, + ); + this.contextValue = ContextValue.LocalBranchesSection; + this.iconPath = new vscode.ThemeIcon("go-to-file"); + } +} diff --git a/src/gopstree/nodes/RemoteBranchesSection.ts b/src/gopstree/nodes/RemoteBranchesSection.ts new file mode 100644 index 0000000..7358f5a --- /dev/null +++ b/src/gopstree/nodes/RemoteBranchesSection.ts @@ -0,0 +1,17 @@ +import { ContextValue } from "../ContextValue"; +import { NodeType } from "./NodeType"; +import { TreeItemModel } from "../TreeItemModel"; +import * as vscode from "vscode"; +import { Constants } from "../../constants/Constants"; + +export class RemoteBranchesSection extends TreeItemModel { + constructor(collapsibleState: vscode.TreeItemCollapsibleState) { + super( + { label: Constants.REMOTE_BRANCHES_LABEL }, + NodeType.Remote, + collapsibleState, + ); + this.contextValue = ContextValue.RemoteBranchesSection; + this.iconPath = new vscode.ThemeIcon("cloud"); + } +} diff --git a/src/gopstree/nodes/StagedChangesSection.ts b/src/gopstree/nodes/StagedChangesSection.ts new file mode 100644 index 0000000..e6e44ea --- /dev/null +++ b/src/gopstree/nodes/StagedChangesSection.ts @@ -0,0 +1,17 @@ +import { ContextValue } from "../ContextValue"; +import { NodeType } from "./NodeType"; +import { TreeItemModel } from "../TreeItemModel"; +import * as vscode from "vscode"; +import { Constants } from "../../constants/Constants"; + +export class StagedChangesSection extends TreeItemModel { + constructor(collapsibleState: vscode.TreeItemCollapsibleState) { + super( + { label: Constants.STAGED_LABEL }, + NodeType.StagedChanges, + collapsibleState, + ); + this.contextValue = ContextValue.StagedChangesSectionEmpty; + this.iconPath = new vscode.ThemeIcon("diff-added"); + } +} diff --git a/src/gopstree/nodes/StashSection.ts b/src/gopstree/nodes/StashSection.ts new file mode 100644 index 0000000..4b33499 --- /dev/null +++ b/src/gopstree/nodes/StashSection.ts @@ -0,0 +1,13 @@ +import { ContextValue } from "../ContextValue"; +import { NodeType } from "./NodeType"; +import { TreeItemModel } from "../TreeItemModel"; +import * as vscode from "vscode"; +import { Constants } from "../../constants/Constants"; + +export class StashSection extends TreeItemModel { + constructor(collapsibleState: vscode.TreeItemCollapsibleState) { + super({ label: Constants.STASH_LABEL }, NodeType.Stash, collapsibleState); + this.contextValue = ContextValue.StashSection; + this.iconPath = new vscode.ThemeIcon("save"); + } +} diff --git a/src/gopstree/nodes/TagsSection.ts b/src/gopstree/nodes/TagsSection.ts new file mode 100644 index 0000000..f49cdf6 --- /dev/null +++ b/src/gopstree/nodes/TagsSection.ts @@ -0,0 +1,13 @@ +import { ContextValue } from "../ContextValue"; +import { NodeType } from "./NodeType"; +import { TreeItemModel } from "../TreeItemModel"; +import * as vscode from "vscode"; +import { Constants } from "../../constants/Constants"; + +export class TagsSection extends TreeItemModel { + constructor(collapsibleState: vscode.TreeItemCollapsibleState) { + super({ label: Constants.TAGS_LABEL }, NodeType.Tags, collapsibleState); + this.contextValue = ContextValue.TagsSection; + this.iconPath = new vscode.ThemeIcon("tag"); + } +} diff --git a/src/services/GitService.ts b/src/services/GitService.ts index 5689a58..6706704 100644 --- a/src/services/GitService.ts +++ b/src/services/GitService.ts @@ -65,6 +65,14 @@ export class GitService { ); } + async unstageAllFiles(): Promise { + await this.executeGitAction( + () => this.git.reset(["HEAD"]), + "Unstaged all files successfully", + "Failed to unstage all files", + ); + } + async getBranches(): Promise { return this.git.branch(); } diff --git a/test/unit/services/GitService.test.ts b/test/unit/services/GitService.test.ts index ac5abb5..980486c 100644 --- a/test/unit/services/GitService.test.ts +++ b/test/unit/services/GitService.test.ts @@ -15,7 +15,11 @@ vi.mock("vscode", () => ({ ], }, window: { - createOutputChannel: vi.fn(() => ({ appendLine: vi.fn(), show: vi.fn(), dispose: vi.fn() })), + createOutputChannel: vi.fn(() => ({ + appendLine: vi.fn(), + show: vi.fn(), + dispose: vi.fn(), + })), showInformationMessage: vi.fn(), showWarningMessage: vi.fn(), showErrorMessage: vi.fn(), @@ -43,6 +47,8 @@ const mockGit = { commit: vi.fn(), pull: vi.fn(), checkoutLocalBranch: vi.fn(), + add: vi.fn(), + reset: vi.fn(), }; vi.mock("simple-git", () => ({ @@ -103,8 +109,12 @@ describe("GitService", () => { const result = await service.checkout("feature"); expect(result).toBe("ok"); - expect(infoSpy).toHaveBeenCalledWith("Checked out branch feature successfully"); - expect(notifySpy).toHaveBeenCalledWith("Checked out branch feature successfully"); + expect(infoSpy).toHaveBeenCalledWith( + "Checked out branch feature successfully", + ); + expect(notifySpy).toHaveBeenCalledWith( + "Checked out branch feature successfully", + ); }); it("returns git status from the repository", async () => { @@ -152,7 +162,9 @@ describe("GitService", () => { it("returns file content from a git ref", async () => { mockGit.show.mockResolvedValue("file contents"); - expect(await service.getFileContent("HEAD", "README.md")).toBe("file contents"); + expect(await service.getFileContent("HEAD", "README.md")).toBe( + "file contents", + ); expect(mockGit.show).toHaveBeenCalledWith(["HEAD:README.md"]); }); @@ -175,6 +187,7 @@ describe("GitService", () => { }); it("logs and notifies on successful commit", async () => { + mockGit.status.mockResolvedValue({ staged: [], files: [] }); mockGit.commit.mockResolvedValue("commit-ok"); const infoSpy = vi.spyOn(Logger, "info"); const notifySpy = vi.spyOn(Notifications, "info"); @@ -182,10 +195,11 @@ describe("GitService", () => { const result = await service.commit("message"); expect(result).toBe("commit-ok"); + expect(mockGit.commit).toHaveBeenCalledWith("message", []); expect(infoSpy).toHaveBeenCalledWith("Commit successful"); expect(notifySpy).toHaveBeenCalledWith("Commit successful"); }); - + it("logs and notifies on successful pull", async () => { mockGit.pull.mockResolvedValue("ok"); const infoSpy = vi.spyOn(Logger, "info"); @@ -203,11 +217,15 @@ describe("GitService", () => { const infoSpy = vi.spyOn(Logger, "info"); const notifySpy = vi.spyOn(Notifications, "info"); - const result = await service.checkoutBranch("feature","main"); + const result = await service.checkoutBranch("feature", "main"); expect(result).toBe("ok"); - expect(infoSpy).toHaveBeenCalledWith("Checked out branch feature successfully"); - expect(notifySpy).toHaveBeenCalledWith("Checked out branch feature successfully"); + expect(infoSpy).toHaveBeenCalledWith( + "Checked out branch feature successfully", + ); + expect(notifySpy).toHaveBeenCalledWith( + "Checked out branch feature successfully", + ); }); it("logs and notifies on successful checkoutLocalBranch", async () => { @@ -219,7 +237,9 @@ describe("GitService", () => { expect(result).toBe("ok"); expect(infoSpy).toHaveBeenCalledWith("Branch feature created successfully"); - expect(notifySpy).toHaveBeenCalledWith("Branch feature created successfully"); + expect(notifySpy).toHaveBeenCalledWith( + "Branch feature created successfully", + ); }); it("logs error and rethrows when checkout fails", async () => { From 4ecab2c394a6b96e2585081b4636315cb3b90350 Mon Sep 17 00:00:00 2001 From: CodeMan X Date: Sun, 31 May 2026 16:05:18 +1000 Subject: [PATCH 8/8] feat(commands): add create tag command and expand integration tests Register the `createTag` command in the `CommandRegistrar` and provide a placeholder implementation in `GitOperationsDelegate`. Refactor the testing suite by: - Updating `test:integration` script in `package.json` to include compilation steps. - Replacing the generic `extension.test.ts` with a structured integration test directory. - Adding comprehensive integration tests for branches, commands, commits, extension activation, and staging/unstaging operations. - Expanding unit tests for `GitService` to cover `stageFile`, `unstageFile`, and `unstageAllFiles` methods. --- package.json | 2 +- src/commands/CommandRegistrar.ts | 1 + src/commands/GitOperationsDelegate.ts | 4 + test/extension.test.ts | 18 --- test/integration/branch.test.ts | 39 +++++ test/integration/commands.test.ts | 69 +++++++++ test/integration/commit.test.ts | 30 ++++ test/integration/extension.test.ts | 64 ++++++++ test/integration/extensionIntegration.test.ts | 103 ------------- test/integration/stageFile.test.ts | 43 ++++++ test/unit/services/GitService.test.ts | 139 +++++++++++++++++- 11 files changed, 389 insertions(+), 123 deletions(-) delete mode 100644 test/extension.test.ts create mode 100644 test/integration/branch.test.ts create mode 100644 test/integration/commands.test.ts create mode 100644 test/integration/commit.test.ts create mode 100644 test/integration/extension.test.ts delete mode 100644 test/integration/extensionIntegration.test.ts create mode 100644 test/integration/stageFile.test.ts diff --git a/package.json b/package.json index ba969a4..d96b9f6 100644 --- a/package.json +++ b/package.json @@ -215,7 +215,7 @@ "check:types": "tsc --noEmit", "lint": "eslint src", "test:unit": "vitest run", - "test:integration": "vscode-test", + "test:integration": "npm run compile && npm run compile:tests && vscode-test", "coverage:unit": "vitest run --coverage --coverage.reporter=lcov --coverage.reportsDirectory=coverage/unit", "coverage:integration": "vscode-test --coverage --coverage-reporter lcov --coverage-output ./coverage/integration" }, diff --git a/src/commands/CommandRegistrar.ts b/src/commands/CommandRegistrar.ts index c427b0d..fda0a12 100644 --- a/src/commands/CommandRegistrar.ts +++ b/src/commands/CommandRegistrar.ts @@ -37,6 +37,7 @@ export class CommandRegistrar { this.delegate.unstageAllFiles(), ); this.register(COMMANDS.COMMIT, () => this.delegate.commit()); + this.register(COMMANDS.CREATE_TAG, () => this.delegate.createTag()); } private register( diff --git a/src/commands/GitOperationsDelegate.ts b/src/commands/GitOperationsDelegate.ts index 88af537..8f977e4 100644 --- a/src/commands/GitOperationsDelegate.ts +++ b/src/commands/GitOperationsDelegate.ts @@ -138,4 +138,8 @@ export class GitOperationsDelegate { this.treeDataProvider.refreshChangesNode(); await this.treeDataProvider.refreshStagedNode(); } + + async createTag(): Promise { + // TODO: implement + } } diff --git a/test/extension.test.ts b/test/extension.test.ts deleted file mode 100644 index f81d1bb..0000000 --- a/test/extension.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import * as assert from "assert"; - -// You can import and use all API from the 'vscode' module -// as well as import your extension to test it -import * as vscode from "vscode"; -// import * as myExtension from '../../extension'; - -declare function suite(name: string, fn: () => void): void; -declare function test(name: string, fn: () => void): void; - -suite("Extension Test Suite", () => { - vscode.window.showInformationMessage("Start all tests."); - - test("Sample test", () => { - assert.strictEqual(-1, [1, 2, 3].indexOf(5)); - assert.strictEqual(-1, [1, 2, 3].indexOf(0)); - }); -}); diff --git a/test/integration/branch.test.ts b/test/integration/branch.test.ts new file mode 100644 index 0000000..4b5d51c --- /dev/null +++ b/test/integration/branch.test.ts @@ -0,0 +1,39 @@ +import * as assert from "node:assert"; +import * as vscode from "vscode"; + +suite("Branch", function () { + this.timeout(30000); + + suiteSetup(async function () { + const extension = vscode.extensions.getExtension("codemanxdev.gops"); + if (!extension?.isActive) { + await extension?.activate(); + } + }); + + test("gops.branch.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"); + + (vscode.window as any).showInputBox = stub; + assert.ok(true, "gops.branch.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("gops.renameBranch should execute without error", async function () { + await vscode.commands.executeCommand("gops.renameBranch"); + assert.ok(true, "gops.renameBranch 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"); + }); +}); diff --git a/test/integration/commands.test.ts b/test/integration/commands.test.ts new file mode 100644 index 0000000..b1805f3 --- /dev/null +++ b/test/integration/commands.test.ts @@ -0,0 +1,69 @@ +import * as assert from "node:assert"; +import * as vscode from "vscode"; + +suite("Commands", function () { + this.timeout(30000); + + suiteSetup(async function () { + const extension = vscode.extensions.getExtension("codemanxdev.gops"); + if (!extension?.isActive) { + await extension?.activate(); + } + }); + + 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", + ]; + + for (const command of expectedCommands) { + assert.ok( + commands.includes(command), + `Command ${command} must be registered`, + ); + } + }); + + test("gops.refresh should execute without error", async function () { + await vscode.commands.executeCommand("gops.refresh"); + assert.ok(true, "gops.refresh completed without error"); + }); + + test("gops.pull should execute without error", async function () { + try { + await vscode.commands.executeCommand("gops.pull"); + assert.ok(true, "gops.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}`, + ); + } + }); + + test("gops.push should execute without error", async function () { + try { + await vscode.commands.executeCommand("gops.push"); + assert.ok(true, "gops.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}`, + ); + } + }); +}); diff --git a/test/integration/commit.test.ts b/test/integration/commit.test.ts new file mode 100644 index 0000000..e9ab734 --- /dev/null +++ b/test/integration/commit.test.ts @@ -0,0 +1,30 @@ +import * as assert from "node:assert"; +import * as vscode from "vscode"; + +suite("Commit", function () { + this.timeout(30000); + + suiteSetup(async function () { + const extension = vscode.extensions.getExtension("codemanxdev.gops"); + if (!extension?.isActive) { + await extension?.activate(); + } + }); + + test("gops.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"); + + (vscode.window as any).showInputBox = stub; + assert.ok(true, "gops.commit completed without error"); + }); + + test("gops.refresh should complete after commit attempt", async function () { + await vscode.commands.executeCommand("gops.refresh"); + await new Promise((resolve) => setTimeout(resolve, 500)); + assert.ok(true, "gops.refresh completed after commit attempt"); + }); +}); diff --git a/test/integration/extension.test.ts b/test/integration/extension.test.ts new file mode 100644 index 0000000..93793a7 --- /dev/null +++ b/test/integration/extension.test.ts @@ -0,0 +1,64 @@ +import * as assert from "node:assert"; +import * as vscode from "vscode"; + +suite("Extension", function () { + this.timeout(30000); + + test("should be activated and execute commands", async function () { + assert.ok( + vscode.workspace.workspaceFolders?.length, + "Workspace folder must be available", + ); + + const extension = vscode.extensions.getExtension("codemanxdev.gops"); + assert.ok(extension, "Extension codemanxdev.gops must be found by VS Code"); + + if (!extension.isActive) { + await extension.activate(); + } + + assert.strictEqual(extension.isActive, true, "Extension must be active"); + + await vscode.commands.executeCommand("gops.refresh"); + assert.ok(true, "gops.refresh must complete without error"); + }); + + test("should register the Git Ops tree view", async function () { + const extension = vscode.extensions.getExtension("codemanxdev.gops"); + if (!extension?.isActive) { + await extension?.activate(); + } + + await new Promise((resolve) => setTimeout(resolve, 500)); + + let treeViewAlreadyRegistered = false; + try { + vscode.window.createTreeView("gitOpsTreeview", { + treeDataProvider: + new (class implements vscode.TreeDataProvider { + getTreeItem(_element: unknown): vscode.TreeItem { + return undefined as unknown as vscode.TreeItem; + } + getChildren(): vscode.TreeItem[] { + return []; + } + })(), + }); + assert.fail("createTreeView should have thrown already exists"); + } catch (err: any) { + if ( + typeof err.message === "string" && + err.message.includes("already exists") + ) { + treeViewAlreadyRegistered = true; + } else { + throw err; + } + } + + assert.ok( + treeViewAlreadyRegistered, + "Tree view must be registered by activate()", + ); + }); +}); diff --git a/test/integration/extensionIntegration.test.ts b/test/integration/extensionIntegration.test.ts deleted file mode 100644 index df2b2c1..0000000 --- a/test/integration/extensionIntegration.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import * as assert from "node:assert"; -import * as vscode from "vscode"; - -/** - * Integration tests for the Gops extension. - * - * The workspace folder is opened by `.vscode-test.mjs` via the `workspaceFolder` - * config field. The folder is a minimal git repo created on-the-fly by the config - * script, so GitService / simple-git have a valid repo to work against. - */ -suite("Extension Integration Test Suite", function () { - this.timeout(30000); - - test("Extension should be activated and execute commands", async function () { - // The workspace is opened by the test runner — confirm it is set - assert.ok( - vscode.workspace.workspaceFolders?.length, - "Workspace folder from workspaceFolder config must be available", - ); - - const extension = vscode.extensions.getExtension("codemanxdev.gops"); - assert.ok(extension, "Extension codemanxdev.gops must be found by VS Code"); - - if (!extension.isActive) { - await extension.activate(); - } - - assert.strictEqual( - extension.isActive, - true, - "Extension must be active after activation", - ); - - // gops.refresh is a no-args command registered by CommandRegistrar in activate(). - // A clean resolve confirms the command registry is intact and tree view is registered. - await vscode.commands.executeCommand("gops.refresh"); - assert.ok(true, "gops.refresh must complete without error"); - }); - - test("Extension should register the Git Ops tree view", async function () { - assert.ok( - vscode.workspace.workspaceFolders?.length, - "Workspace folder from workspaceFolder config must be available", - ); - - const extension = vscode.extensions.getExtension("codemanxdev.gops"); - assert.ok(extension, "Extension codemanxdev.gops must be found by VS Code"); - - if (!extension.isActive) { - await extension.activate(); - } - - // Allow tree data provider to populate once - await new Promise((resolve) => setTimeout(resolve, 500)); - - assert.strictEqual( - extension.isActive, - true, - "Extension must be active before checking tree view registration", - ); - - // extension.ts activate() calls: - // vscode.window.createTreeView('gitOpsTreeview', { treeDataProvider }) - // Calling createTreeView again with the same id throws VS Code error E303 - // ('Object for type "view" already exists'). Catching that error proves the - // extension registered the tree view in activate(). - let treeViewAlreadyRegistered = false; - try { - vscode.window.createTreeView("gitOpsTreeview", { - treeDataProvider: - new (class implements vscode.TreeDataProvider { - getTreeItem( - _element: unknown, - ): vscode.TreeItem | Thenable { - return undefined as unknown as vscode.TreeItem; - } - getChildren(): vscode.TreeItem[] | Thenable { - return []; - } - })(), - }); - // If we reach here the view was NOT registered — fail - assert.fail( - 'createTreeView should have thrown "already exists" since activate() registered the view first', - ); - } catch (err: any) { - if ( - typeof err.message === "string" && - err.message.includes("already exists") - ) { - treeViewAlreadyRegistered = true; - } else { - throw err; - } - } - - assert.ok( - treeViewAlreadyRegistered, - 'createTreeView("gitOpsTreeview") must throw "already exists" — ' + - "proving the extension registered the Git Ops tree view in activate()", - ); - }); -}); diff --git a/test/integration/stageFile.test.ts b/test/integration/stageFile.test.ts new file mode 100644 index 0000000..3dd8991 --- /dev/null +++ b/test/integration/stageFile.test.ts @@ -0,0 +1,43 @@ +import * as assert from "node:assert"; +import * as vscode from "vscode"; +import * as path from "node:path"; +import * as fs from "node:fs/promises"; + +suite("Stage/Unstage", function () { + this.timeout(30000); + + const workspacePath = () => vscode.workspace.workspaceFolders![0].uri.fsPath; + + suiteSetup(async function () { + const extension = vscode.extensions.getExtension("codemanxdev.gops"); + if (!extension?.isActive) { + await extension?.activate(); + } + }); + + setup(async function () { + // Create a test file before each test + await fs.writeFile( + path.join(workspacePath(), "stage-test.md"), + "test content", + ); + await new Promise((resolve) => setTimeout(resolve, 200)); + }); + + teardown(async function () { + // Clean up test file after each test + await fs.rm(path.join(workspacePath(), "stage-test.md"), { force: true }); + 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("gops.refresh should reflect file changes", async function () { + await vscode.commands.executeCommand("gops.refresh"); + await new Promise((resolve) => setTimeout(resolve, 500)); + assert.ok(true, "gops.refresh completed after file change"); + }); +}); diff --git a/test/unit/services/GitService.test.ts b/test/unit/services/GitService.test.ts index 980486c..d4ce8d0 100644 --- a/test/unit/services/GitService.test.ts +++ b/test/unit/services/GitService.test.ts @@ -199,7 +199,7 @@ describe("GitService", () => { expect(infoSpy).toHaveBeenCalledWith("Commit successful"); expect(notifySpy).toHaveBeenCalledWith("Commit successful"); }); - + it("logs and notifies on successful pull", async () => { mockGit.pull.mockResolvedValue("ok"); const infoSpy = vi.spyOn(Logger, "info"); @@ -256,4 +256,141 @@ describe("GitService", () => { "Checkout failed for branch feature. See details in output", ); }); + + it("logs and notifies on successful stageFile", async () => { + mockGit.add.mockResolvedValue("ok"); + const infoSpy = vi.spyOn(Logger, "info"); + const notifySpy = vi.spyOn(Notifications, "info"); + + await service.stageFile("src/file.ts"); + + expect(mockGit.add).toHaveBeenCalledWith("src/file.ts"); + expect(infoSpy).toHaveBeenCalledWith( + "Staged file src/file.ts successfully", + ); + expect(notifySpy).toHaveBeenCalledWith( + "Staged file src/file.ts successfully", + ); + }); + + it("logs error and rethrows when stageFile fails", async () => { + const error = new Error("stage failed"); + mockGit.add.mockRejectedValue(error); + const errorSpy = vi.spyOn(Logger, "error"); + const notifySpy = vi.spyOn(Notifications, "errorWithOutput"); + + await expect(service.stageFile("src/file.ts")).rejects.toThrow(error); + expect(errorSpy).toHaveBeenCalledWith( + "Failed to stage file src/file.ts: stage failed", + ); + expect(notifySpy).toHaveBeenCalledWith( + "Failed to stage file src/file.ts. See details in output", + ); + }); + + it("logs and notifies on successful unstageFile", async () => { + mockGit.reset.mockResolvedValue("ok"); + const infoSpy = vi.spyOn(Logger, "info"); + const notifySpy = vi.spyOn(Notifications, "info"); + + await service.unstageFile("src/file.ts"); + + expect(mockGit.reset).toHaveBeenCalledWith(["HEAD", "src/file.ts"]); + expect(infoSpy).toHaveBeenCalledWith( + "Unstaged file src/file.ts successfully", + ); + expect(notifySpy).toHaveBeenCalledWith( + "Unstaged file src/file.ts successfully", + ); + }); + + it("logs error and rethrows when unstageFile fails", async () => { + const error = new Error("unstage failed"); + mockGit.reset.mockRejectedValue(error); + const errorSpy = vi.spyOn(Logger, "error"); + const notifySpy = vi.spyOn(Notifications, "errorWithOutput"); + + await expect(service.unstageFile("src/file.ts")).rejects.toThrow(error); + expect(errorSpy).toHaveBeenCalledWith( + "Failed to unstage file src/file.ts: unstage failed", + ); + expect(notifySpy).toHaveBeenCalledWith( + "Failed to unstage file src/file.ts. See details in output", + ); + }); + + it("logs and notifies on successful unstageAllFiles", async () => { + mockGit.reset.mockResolvedValue("ok"); + const infoSpy = vi.spyOn(Logger, "info"); + const notifySpy = vi.spyOn(Notifications, "info"); + + await service.unstageAllFiles(); + + expect(mockGit.reset).toHaveBeenCalledWith(["HEAD"]); + expect(infoSpy).toHaveBeenCalledWith("Unstaged all files successfully"); + expect(notifySpy).toHaveBeenCalledWith("Unstaged all files successfully"); + }); + + it("logs error and rethrows when unstageAllFiles fails", async () => { + const error = new Error("unstage all failed"); + mockGit.reset.mockRejectedValue(error); + const errorSpy = vi.spyOn(Logger, "error"); + const notifySpy = vi.spyOn(Notifications, "errorWithOutput"); + + await expect(service.unstageAllFiles()).rejects.toThrow(error); + expect(errorSpy).toHaveBeenCalledWith( + "Failed to unstage all files: unstage all failed", + ); + expect(notifySpy).toHaveBeenCalledWith( + "Failed to unstage all files. See details in output", + ); + }); + + it("returns only unstaged changed files", async () => { + mockGit.status.mockResolvedValue({ + files: [ + { path: "src/modified.ts", index: " ", working_dir: "M" }, + { path: "src/staged.ts", index: "M", working_dir: " " }, + { path: "src/untracked.ts", index: "?", working_dir: "?" }, + ], + }); + + const result = await service.getChangedFiles(); + + expect(result).toEqual(["src/modified.ts"]); + }); + + it("returns empty array when no unstaged files", async () => { + mockGit.status.mockResolvedValue({ + files: [{ path: "src/staged.ts", index: "M", working_dir: " " }], + }); + + const result = await service.getChangedFiles(); + + expect(result).toEqual([]); + }); + + it("returns only staged files", async () => { + mockGit.status.mockResolvedValue({ + files: [ + { path: "src/modified.ts", index: " ", working_dir: "M" }, + { path: "src/staged.ts", index: "M", working_dir: " " }, + { path: "src/untracked.ts", index: "?", working_dir: "?" }, + ], + }); + + const result = await service.getStagedFiles(); + + expect(result).toEqual(["src/staged.ts"]); + }); + + it("returns empty array when no staged files", async () => { + mockGit.status.mockResolvedValue({ + files: [{ path: "src/modified.ts", index: " ", working_dir: "M" }], + }); + + const result = await service.getStagedFiles(); + + expect(result).toEqual([]); + }); });