diff --git a/media/gitGraph.css b/media/gitGraph.css new file mode 100644 index 0000000..a7a9abc --- /dev/null +++ b/media/gitGraph.css @@ -0,0 +1,147 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background: var(--vscode-editor-background); + color: var(--vscode-editor-foreground); + font-family: var(--vscode-editor-font-family, "Courier New", monospace); + font-size: 13px; + height: 100vh; + overflow: hidden; +} + +#header { + padding: 12px 16px; + border-bottom: 1px solid var(--vscode-panel-border); + display: flex; + align-items: center; + gap: 8px; + background: var(--vscode-sideBarSectionHeader-background); +} + +#header h2 { + font-size: 13px; + font-weight: 600; + color: var(--vscode-sideBarSectionHeader-foreground); + font-family: var(--vscode-font-family); + letter-spacing: 0.5px; +} + +#header .branch-badge { + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; +} + +#header .commit-count { + color: var(--vscode-descriptionForeground); + font-size: 11px; + margin-left: auto; +} + +#table-container { + overflow-y: auto; + height: calc(100vh - 49px); +} + +table { + width: 100%; + border-collapse: collapse; +} + +thead { + position: sticky; + top: 0; + z-index: 1; + background: var(--vscode-editor-background); +} + +thead tr { + border-bottom: 2px solid var(--vscode-panel-border); + background: var(--vscode-sideBarSectionHeader-background); +} + +th { + padding: 8px 12px; + text-align: left; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1.5px; + color: var(--vscode-sideBarSectionHeader-foreground); + white-space: nowrap; + border-right: 1px solid var(--vscode-panel-border); + font-family: + "Segoe UI", + system-ui, + -apple-system, + sans-serif; +} + +th:last-child { + border-right: none; +} + +tbody tr { + border-bottom: 1px solid var(--vscode-panel-border, rgba(255, 255, 255, 0.05)); + cursor: pointer; + transition: background 0.1s; +} + +tbody tr:nth-child(even) { + background: var( + --vscode-list-inactiveSelectionBackground, + rgba(255, 255, 255, 0.02) + ); +} + +tbody tr:hover { + background: var(--vscode-list-hoverBackground); +} + +td { + padding: 8px 12px; + vertical-align: middle; +} + +.graph-cell { + width: 40px; + padding: 0 8px; +} + +.hash { + font-family: monospace; + color: var(--vscode-textLink-foreground); + font-size: 12px; + white-space: nowrap; +} + +.message { + color: var(--vscode-editor-foreground); + max-width: 400px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.author { + color: var(--vscode-terminal-ansiGreen); + white-space: nowrap; + font-size: 12px; +} + +.date { + color: var(--vscode-descriptionForeground); + white-space: nowrap; + font-size: 12px; +} + +svg.graph { + display: block; + overflow: visible; +} diff --git a/package.json b/package.json index 21901ad..2e1000e 100644 --- a/package.json +++ b/package.json @@ -44,10 +44,6 @@ "main": "./dist/extension.js", "contributes": { "commands": [ - { - "command": "gops.helloWorld", - "title": "Gops Hello World" - }, { "command": "gops.refresh", "title": "Refresh Git Ops View", @@ -122,6 +118,11 @@ "command": "gops.stageAllFiles", "title": "Stage All Files", "icon": "$(expand-all)" + }, + { + "command": "gops.showGitGraph", + "title": "Show Git Graph", + "icon": "$(graph)" } ], "viewsContainers": { diff --git a/src/commands/CommandRegistrar.ts b/src/commands/CommandRegistrar.ts index 7ddd061..5d4a3d4 100644 --- a/src/commands/CommandRegistrar.ts +++ b/src/commands/CommandRegistrar.ts @@ -41,6 +41,8 @@ export class CommandRegistrar { ); this.register(COMMANDS.COMMIT, () => this.delegate.commit()); this.register(COMMANDS.CREATE_TAG, () => this.delegate.createTag()); + this.register(COMMANDS.SHOW_GIT_GRAPH, (node) => this.delegate.showGitGraph(node) + ); } private register( diff --git a/src/commands/Commands.ts b/src/commands/Commands.ts index c699e0b..cb859f1 100644 --- a/src/commands/Commands.ts +++ b/src/commands/Commands.ts @@ -14,4 +14,5 @@ export const COMMANDS = { UNSTAGE_FILE: "gops.unstageFile", UNSTAGE_ALL_FILES: "gops.unstageAllFiles", COMMIT: "gops.commit", + SHOW_GIT_GRAPH: "gops.showGitGraph", } as const; diff --git a/src/commands/GitOperationsDelegate.ts b/src/commands/GitOperationsDelegate.ts index 2f8136a..d64d109 100644 --- a/src/commands/GitOperationsDelegate.ts +++ b/src/commands/GitOperationsDelegate.ts @@ -5,6 +5,7 @@ import { TreeDataProvider } from "../gopstree/TreeDataProvider"; import { GitTreeNode } from "../gopstree/types"; import { ChangedFileNode } from "../gopstree/nodes/ChangedFileNode"; import { StagedFileNode } from "../gopstree/nodes/StagedFileNode"; +import { GitGraphPanel } from "../gopswebpanel/GitGraphPanel"; export class GitOperationsDelegate { constructor( @@ -173,4 +174,8 @@ export class GitOperationsDelegate { async createTag(): Promise { // TODO: implement } + + async showGitGraph(branchName: string): Promise { + await GitGraphPanel.createOrShow(branchName, this.gitService); + } } diff --git a/src/gopstree/nodes/LocalBranchNode.ts b/src/gopstree/nodes/LocalBranchNode.ts index 148a1d4..dbfe4c1 100644 --- a/src/gopstree/nodes/LocalBranchNode.ts +++ b/src/gopstree/nodes/LocalBranchNode.ts @@ -1,17 +1,27 @@ import { ContextValue } from "../ContextValue"; import { NodeType } from "./NodeType"; import { TreeItemModel } from "../TreeItemModel"; -import * as vscode from 'vscode'; -import { createLocalBranchTooltip, formatLocalBranchLabel } from "./utils/nodeUtils"; +import * as vscode from "vscode"; +import { + createLocalBranchTooltip, + formatLocalBranchLabel, +} from "./utils/nodeUtils"; +import { COMMANDS } from "../../commands/Commands"; export class LocalBranchNode extends TreeItemModel { + public override command?: vscode.Command; constructor( public readonly branchName: string, public readonly isCurrent: boolean, public readonly ahead?: number, public readonly behind?: number, ) { - const fomatted = formatLocalBranchLabel(branchName, isCurrent, ahead, behind); + const fomatted = formatLocalBranchLabel( + branchName, + isCurrent, + ahead, + behind, + ); super( { label: fomatted.label, @@ -20,12 +30,20 @@ export class LocalBranchNode extends TreeItemModel { NodeType.Local, vscode.TreeItemCollapsibleState.None, ); - this.contextValue = isCurrent ? ContextValue.LocalBranchesCurrent : ContextValue.LocalBranches; + this.contextValue = isCurrent + ? ContextValue.LocalBranchesCurrent + : ContextValue.LocalBranches; + + this.command = { + command: COMMANDS.SHOW_GIT_GRAPH, + title: "Open Git Graph", + arguments: [this.branchName], + }; if (isCurrent) { this.iconPath = new vscode.ThemeIcon("check"); } - + this.tooltip = createLocalBranchTooltip( branchName, isCurrent, @@ -37,4 +55,4 @@ export class LocalBranchNode extends TreeItemModel { public toString(): string { return `LocalBranchNode(${this.branchName}, current=${this.isCurrent}, ahead=${this.ahead}, behind=${this.behind}, contextValue=${this.contextValue})`; } -} \ No newline at end of file +} diff --git a/src/gopswebpanel/GitGraphPanel.ts b/src/gopswebpanel/GitGraphPanel.ts new file mode 100644 index 0000000..85e2d6a --- /dev/null +++ b/src/gopswebpanel/GitGraphPanel.ts @@ -0,0 +1,31 @@ +import * as vscode from "vscode"; +import { GitService } from "../services/GitService"; +import { renderGitGraph } from "./GitGraphWebview"; + +export class GitGraphPanel { + private static currentPanel: GitGraphPanel | undefined; + + private constructor(private readonly panel: vscode.WebviewPanel) {} + + public static async createOrShow(branchName: string, gitService: GitService) { + const extensionUri = + vscode.extensions.getExtension("codemanxdev.gops")!.extensionUri; + const panel = vscode.window.createWebviewPanel( + "gitGraph", + `Git Graph: ${branchName}`, + vscode.ViewColumn.One, + { enableScripts: true }, + ); + + GitGraphPanel.currentPanel = new GitGraphPanel(panel); + await GitGraphPanel.currentPanel.render(extensionUri, branchName, gitService); + } + + private async render(extensionUri: vscode.Uri, branchName: string, gitService: GitService) { + const commits = await gitService.getBranchCommits(branchName); + const cssUri = this.panel.webview.asWebviewUri( + vscode.Uri.joinPath(extensionUri, "media", "gitGraph.css"), + ); + this.panel.webview.html = renderGitGraph(branchName, commits, cssUri); + } +} diff --git a/src/gopswebpanel/GitGraphWebview.ts b/src/gopswebpanel/GitGraphWebview.ts new file mode 100644 index 0000000..4be101b --- /dev/null +++ b/src/gopswebpanel/GitGraphWebview.ts @@ -0,0 +1,109 @@ +import * as vscode from "vscode"; +import { GitCommitModel } from "../models/GitCommitModel"; + +export function renderGitGraph( + branchName: string, + commits: GitCommitModel[], + cssUri: vscode.Uri, +): string { + return ` + + + + + + + +
+ + + + + + + + + + + + +
HashMessageAuthorDate
+
+ + + + `; +} diff --git a/src/models/GitCommitModel.ts b/src/models/GitCommitModel.ts new file mode 100644 index 0000000..f79f835 --- /dev/null +++ b/src/models/GitCommitModel.ts @@ -0,0 +1,6 @@ +export interface GitCommitModel { + hash: string; + message: string; + author: string; + date: string; +} diff --git a/src/services/GitService.ts b/src/services/GitService.ts index 8b3f7ff..cc7e72e 100644 --- a/src/services/GitService.ts +++ b/src/services/GitService.ts @@ -199,6 +199,29 @@ export class GitService { `Failed to create branch ${branchName}`, ); } + + async getBranchCommits(branchName: string): Promise< + { + hash: string; + message: string; + author: string; + date: string; + }[] + > { + return this.executeGitAction( + async () => { + const log = await this.git.log([branchName]); + return log.all.map((c) => ({ + hash: c.hash.substring(0, 7), + message: c.message, + author: c.author_name, + date: c.date, + })); + }, + `Loaded commits for branch ${branchName}`, + `Failed to load commits for branch ${branchName}`, + ); + } // #endregion getRepoName(): string { diff --git a/test/unit/services/GitService.test.ts b/test/unit/services/GitService.test.ts index d4ce8d0..8e9a653 100644 --- a/test/unit/services/GitService.test.ts +++ b/test/unit/services/GitService.test.ts @@ -49,6 +49,8 @@ const mockGit = { checkoutLocalBranch: vi.fn(), add: vi.fn(), reset: vi.fn(), + deleteLocalBranch: vi.fn(), + raw: vi.fn(), }; vi.mock("simple-git", () => ({ @@ -393,4 +395,102 @@ describe("GitService", () => { expect(result).toEqual([]); }); + + it("logs and notifies on successful stageAllFiles", async () => { + mockGit.add.mockResolvedValue("ok"); + const infoSpy = vi.spyOn(Logger, "info"); + const notifySpy = vi.spyOn(Notifications, "info"); + + await service.stageAllFiles(); + + expect(mockGit.add).toHaveBeenCalledWith("."); + expect(infoSpy).toHaveBeenCalledWith("Staged all files successfully"); + expect(notifySpy).toHaveBeenCalledWith("Staged all files successfully"); + }); + + it("logs error and rethrows when stageAllFiles fails", async () => { + const error = new Error("stage all failed"); + mockGit.add.mockRejectedValue(error); + const errorSpy = vi.spyOn(Logger, "error"); + const notifySpy = vi.spyOn(Notifications, "errorWithOutput"); + + await expect(service.stageAllFiles()).rejects.toThrow(error); + expect(errorSpy).toHaveBeenCalledWith( + "Failed to stage all files: stage all failed", + ); + expect(notifySpy).toHaveBeenCalledWith( + "Failed to stage all files. See details in output", + ); + }); + + it("logs and notifies on successful deleteBranch", async () => { + mockGit.deleteLocalBranch.mockResolvedValue("ok"); + const infoSpy = vi.spyOn(Logger, "info"); + const notifySpy = vi.spyOn(Notifications, "info"); + + await service.deleteBranch("feature/my-branch"); + + expect(mockGit.deleteLocalBranch).toHaveBeenCalledWith("feature/my-branch"); + expect(infoSpy).toHaveBeenCalledWith( + "Branch feature/my-branch deleted successfully", + ); + expect(notifySpy).toHaveBeenCalledWith( + "Branch feature/my-branch deleted successfully", + ); + }); + + it("logs error and rethrows when deleteBranch fails", async () => { + const error = new Error("delete failed"); + mockGit.deleteLocalBranch.mockRejectedValue(error); + const errorSpy = vi.spyOn(Logger, "error"); + const notifySpy = vi.spyOn(Notifications, "errorWithOutput"); + + await expect(service.deleteBranch("feature/my-branch")).rejects.toThrow( + error, + ); + expect(errorSpy).toHaveBeenCalledWith( + "Failed to delete branch feature/my-branch: delete failed", + ); + expect(notifySpy).toHaveBeenCalledWith( + "Failed to delete branch feature/my-branch. See details in output", + ); + }); + + it("logs and notifies on successful renameBranch", async () => { + mockGit.raw.mockResolvedValue("ok"); + const infoSpy = vi.spyOn(Logger, "info"); + const notifySpy = vi.spyOn(Notifications, "info"); + + await service.renameBranch("old-branch", "new-branch"); + + expect(mockGit.raw).toHaveBeenCalledWith([ + "branch", + "-m", + "old-branch", + "new-branch", + ]); + expect(infoSpy).toHaveBeenCalledWith( + "Branch renamed to new-branch successfully", + ); + expect(notifySpy).toHaveBeenCalledWith( + "Branch renamed to new-branch successfully", + ); + }); + + it("logs error and rethrows when renameBranch fails", async () => { + const error = new Error("rename failed"); + mockGit.raw.mockRejectedValue(error); + const errorSpy = vi.spyOn(Logger, "error"); + const notifySpy = vi.spyOn(Notifications, "errorWithOutput"); + + await expect( + service.renameBranch("old-branch", "new-branch"), + ).rejects.toThrow(error); + expect(errorSpy).toHaveBeenCalledWith( + "Failed to rename branch old-branch: rename failed", + ); + expect(notifySpy).toHaveBeenCalledWith( + "Failed to rename branch old-branch. See details in output", + ); + }); });