Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
11. **Run validation before commits** - Run `dotnet build` and verify no errors before committing
12. **No co-authors** - Do not add co-author information on commits or pull requests
13. **No "generated by" statements** - Do not add generated-by statements on pull requests
14. **MEF for services** - Follow MEF rules in VSIX Development Rules section below
15. **No async void** - Do not use `async void`, use `async Task` instead

---

Expand Down Expand Up @@ -46,6 +48,15 @@ gh issue close <number>

### VSIX Development Rules

**MEF (Managed Extensibility Framework) - REQUIRED:**

1. Services must be interface-backed and use MEF attributes appropriately (`[Export(typeof(IService))]`, `[ImportingConstructor]`, `[PartCreationPolicy]`)
2. The package class should only retrieve from the component model what it absolutely needs to finish initialization - do not grab all services preemptively
3. Services must NOT be exposed as global static properties on the package class
4. MEF-composed classes must use a constructor flagged with `[ImportingConstructor]` with service dependencies as parameters
5. For classes that cannot be MEF-constructed (e.g., `BlameAdornment` created by a factory), the factory should import services via MEF and pass them as constructor parameters to the created class
6. Prefer constructor injection over property injection

**Solution & Project Structure:**
- SLNX solution files only (no legacy .sln)
- Solution naming: `CodingWithCalvin.<ProjectFolder>`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="LibGit2Sharp" Version="0.30.0" />
<PackageReference Include="LibGit2Sharp" Version="0.31.0" />
<PackageReference Include="SkiaSharp" Version="2.88.7" />
</ItemGroup>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<PackageReference Include="Community.VisualStudio.Toolkit.17" Version="17.0.507" />
<PackageReference Include="Microsoft.VisualStudio.SDK" Version="17.14.40265" ExcludeAssets="runtime" />
<PackageReference Include="Microsoft.VSSDK.BuildTools" Version="17.*" PrivateAssets="all" />
<PackageReference Include="LibGit2Sharp" Version="0.30.0" />
<PackageReference Include="LibGit2Sharp" Version="0.31.0" />
<PackageReference Include="SkiaSharp" Version="2.88.7" />
<PackageReference Include="SkiaSharp.Views.WPF" Version="2.88.7" />
</ItemGroup>
Expand Down
275 changes: 144 additions & 131 deletions src/CodingWithCalvin.GitRanger/Commands/BlameCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,174 +3,187 @@
using System.ComponentModel.Design;
using System.Threading.Tasks;
using CodingWithCalvin.GitRanger.Options;
using CodingWithCalvin.GitRanger.Services;
using CodingWithCalvin.Otel4Vsix;
using Community.VisualStudio.Toolkit;
using Microsoft.VisualStudio.ComponentModelHost;
using Microsoft.VisualStudio.Shell;
using Task = System.Threading.Tasks.Task;

namespace CodingWithCalvin.GitRanger.Commands
namespace CodingWithCalvin.GitRanger.Commands;

/// <summary>
/// Commands related to blame functionality.
/// </summary>
internal static class BlameCommands
{
/// <summary>
/// Commands related to blame functionality.
/// </summary>
internal static class BlameCommands
private static IGitService? _gitService;
private static IBlameService? _blameService;

public static async Task InitializeAsync(AsyncPackage package)
{
public static async Task InitializeAsync(AsyncPackage package)
await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();

// Get services from MEF
var componentModel = await package.GetServiceAsync(typeof(SComponentModel)) as IComponentModel;
if (componentModel != null)
{
await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();
_gitService = componentModel.GetService<IGitService>();
_blameService = componentModel.GetService<IBlameService>();
}

var commandService = await package.GetServiceAsync(typeof(IMenuCommandService)) as OleMenuCommandService;
if (commandService == null)
return;
var commandService = await package.GetServiceAsync(typeof(IMenuCommandService)) as OleMenuCommandService;
if (commandService == null)
return;

// Toggle Inline Blame command
var toggleInlineBlameId = new CommandID(VSCommandTableVsct.guidGitRangerPackageCmdSet.Guid, VSCommandTableVsct.guidGitRangerPackageCmdSet.cmdidToggleInlineBlame);
var toggleInlineBlameCommand = new OleMenuCommand(OnToggleInlineBlame, toggleInlineBlameId);
toggleInlineBlameCommand.BeforeQueryStatus += OnBeforeQueryStatusToggleInlineBlame;
commandService.AddCommand(toggleInlineBlameCommand);

// Toggle Blame Gutter command
var toggleBlameGutterId = new CommandID(VSCommandTableVsct.guidGitRangerPackageCmdSet.Guid, VSCommandTableVsct.guidGitRangerPackageCmdSet.cmdidToggleBlameGutter);
var toggleBlameGutterCommand = new OleMenuCommand(OnToggleBlameGutter, toggleBlameGutterId);
toggleBlameGutterCommand.BeforeQueryStatus += OnBeforeQueryStatusToggleBlameGutter;
commandService.AddCommand(toggleBlameGutterCommand);

// Copy Commit SHA command
var copyCommitShaId = new CommandID(VSCommandTableVsct.guidGitRangerPackageCmdSet.Guid, VSCommandTableVsct.guidGitRangerPackageCmdSet.cmdidCopyCommitSha);
var copyCommitShaCommand = new OleMenuCommand(OnCopyCommitSha, copyCommitShaId);
copyCommitShaCommand.BeforeQueryStatus += OnBeforeQueryStatusCopyCommitSha;
commandService.AddCommand(copyCommitShaCommand);
}

// Toggle Inline Blame command
var toggleInlineBlameId = new CommandID(PackageGuids.guidGitRangerPackageCmdSet, PackageIds.cmdidToggleInlineBlame);
var toggleInlineBlameCommand = new OleMenuCommand(OnToggleInlineBlame, toggleInlineBlameId);
toggleInlineBlameCommand.BeforeQueryStatus += OnBeforeQueryStatusToggleInlineBlame;
commandService.AddCommand(toggleInlineBlameCommand);

// Toggle Blame Gutter command
var toggleBlameGutterId = new CommandID(PackageGuids.guidGitRangerPackageCmdSet, PackageIds.cmdidToggleBlameGutter);
var toggleBlameGutterCommand = new OleMenuCommand(OnToggleBlameGutter, toggleBlameGutterId);
toggleBlameGutterCommand.BeforeQueryStatus += OnBeforeQueryStatusToggleBlameGutter;
commandService.AddCommand(toggleBlameGutterCommand);

// Copy Commit SHA command
var copyCommitShaId = new CommandID(PackageGuids.guidGitRangerPackageCmdSet, PackageIds.cmdidCopyCommitSha);
var copyCommitShaCommand = new OleMenuCommand(OnCopyCommitSha, copyCommitShaId);
copyCommitShaCommand.BeforeQueryStatus += OnBeforeQueryStatusCopyCommitSha;
commandService.AddCommand(copyCommitShaCommand);
}
private static void OnBeforeQueryStatusToggleInlineBlame(object sender, EventArgs e)
{
ThreadHelper.ThrowIfNotOnUIThread();

private static void OnBeforeQueryStatusToggleInlineBlame(object sender, EventArgs e)
if (sender is OleMenuCommand command)
{
ThreadHelper.ThrowIfNotOnUIThread();

if (sender is OleMenuCommand command)
{
var options = GeneralOptions.Instance;
var isEnabled = options?.EnableInlineBlame ?? true;
command.Text = isEnabled
? "Disable Inline Blame"
: "Enable Inline Blame";
command.Enabled = true;
command.Visible = true;
}
var options = GeneralOptions.Instance;
var isEnabled = options?.EnableInlineBlame ?? true;
command.Text = isEnabled
? "Disable Inline Blame"
: "Enable Inline Blame";
command.Enabled = true;
command.Visible = true;
}
}

private static void OnToggleInlineBlame(object sender, EventArgs e)
private static void OnToggleInlineBlame(object sender, EventArgs e)
{
ThreadHelper.ThrowIfNotOnUIThread();

using var activity = VsixTelemetry.StartCommandActivity("GitRanger.ToggleInlineBlame");

var options = GeneralOptions.Instance;
if (options != null)
{
ThreadHelper.ThrowIfNotOnUIThread();
options.EnableInlineBlame = !options.EnableInlineBlame;
options.Save();

using var activity = VsixTelemetry.StartCommandActivity("GitRanger.ToggleInlineBlame");
var status = options.EnableInlineBlame ? "enabled" : "disabled";
activity?.SetTag("inline_blame.enabled", options.EnableInlineBlame);
VsixTelemetry.LogInformation("Inline blame {Status}", status);
VS.StatusBar.ShowMessageAsync($"Git Ranger: Inline blame {status}").FireAndForget();
}
}

var options = GeneralOptions.Instance;
if (options != null)
{
options.EnableInlineBlame = !options.EnableInlineBlame;
options.Save();
private static void OnBeforeQueryStatusToggleBlameGutter(object sender, EventArgs e)
{
ThreadHelper.ThrowIfNotOnUIThread();

var status = options.EnableInlineBlame ? "enabled" : "disabled";
activity?.SetTag("inline_blame.enabled", options.EnableInlineBlame);
VsixTelemetry.LogInformation("Inline blame {Status}", status);
VS.StatusBar.ShowMessageAsync($"Git Ranger: Inline blame {status}").FireAndForget();
}
if (sender is OleMenuCommand command)
{
var options = GeneralOptions.Instance;
var isEnabled = options?.EnableBlameGutter ?? true;
command.Text = isEnabled
? "Disable Blame Gutter"
: "Enable Blame Gutter";
command.Enabled = true;
command.Visible = true;
}
}

private static void OnBeforeQueryStatusToggleBlameGutter(object sender, EventArgs e)
private static void OnToggleBlameGutter(object sender, EventArgs e)
{
ThreadHelper.ThrowIfNotOnUIThread();

using var activity = VsixTelemetry.StartCommandActivity("GitRanger.ToggleBlameGutter");

var options = GeneralOptions.Instance;
if (options != null)
{
ThreadHelper.ThrowIfNotOnUIThread();
options.EnableBlameGutter = !options.EnableBlameGutter;
options.Save();

if (sender is OleMenuCommand command)
{
var options = GeneralOptions.Instance;
var isEnabled = options?.EnableBlameGutter ?? true;
command.Text = isEnabled
? "Disable Blame Gutter"
: "Enable Blame Gutter";
command.Enabled = true;
command.Visible = true;
}
var status = options.EnableBlameGutter ? "enabled" : "disabled";
activity?.SetTag("blame_gutter.enabled", options.EnableBlameGutter);
VsixTelemetry.LogInformation("Blame gutter {Status}", status);
VS.StatusBar.ShowMessageAsync($"Git Ranger: Blame gutter {status}").FireAndForget();
}
}

private static void OnBeforeQueryStatusCopyCommitSha(object sender, EventArgs e)
{
ThreadHelper.ThrowIfNotOnUIThread();

private static void OnToggleBlameGutter(object sender, EventArgs e)
if (sender is OleMenuCommand command)
{
ThreadHelper.ThrowIfNotOnUIThread();
var isInRepo = _gitService?.IsInRepository ?? false;
command.Enabled = isInRepo;
command.Visible = true;
}
}

using var activity = VsixTelemetry.StartCommandActivity("GitRanger.ToggleBlameGutter");
private static void OnCopyCommitSha(object sender, EventArgs e)
{
_ = OnCopyCommitShaAsync();
}

var options = GeneralOptions.Instance;
if (options != null)
{
options.EnableBlameGutter = !options.EnableBlameGutter;
options.Save();
private static async Task OnCopyCommitShaAsync()
{
await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();

var status = options.EnableBlameGutter ? "enabled" : "disabled";
activity?.SetTag("blame_gutter.enabled", options.EnableBlameGutter);
VsixTelemetry.LogInformation("Blame gutter {Status}", status);
VS.StatusBar.ShowMessageAsync($"Git Ranger: Blame gutter {status}").FireAndForget();
}
}
using var activity = VsixTelemetry.StartCommandActivity("GitRanger.CopyCommitSha");

private static void OnBeforeQueryStatusCopyCommitSha(object sender, EventArgs e)
try
{
ThreadHelper.ThrowIfNotOnUIThread();
var docView = await VS.Documents.GetActiveDocumentViewAsync();
if (docView?.TextView == null)
return;

if (sender is OleMenuCommand command)
{
// Only enable if we're in a git repository and have blame data
var isInRepo = GitRangerPackage.GitService?.IsInRepository ?? false;
command.Enabled = isInRepo;
command.Visible = true;
}
}
var filePath = docView.FilePath;
if (string.IsNullOrEmpty(filePath))
return;

private static async void OnCopyCommitSha(object sender, EventArgs e)
{
await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();
var caretPosition = docView.TextView.Caret.Position.BufferPosition;
var lineNumber = docView.TextView.TextSnapshot.GetLineNumberFromPosition(caretPosition.Position) + 1;

using var activity = VsixTelemetry.StartCommandActivity("GitRanger.CopyCommitSha");
activity?.SetTag("line.number", lineNumber);

try
var blameInfo = _blameService?.GetBlameForLine(filePath, lineNumber);
if (blameInfo != null)
{
var docView = await VS.Documents.GetActiveDocumentViewAsync();
if (docView?.TextView == null)
return;

var filePath = docView.FilePath;
if (string.IsNullOrEmpty(filePath))
return;

// Get the current line
var caretPosition = docView.TextView.Caret.Position.BufferPosition;
var lineNumber = docView.TextView.TextSnapshot.GetLineNumberFromPosition(caretPosition.Position) + 1;

activity?.SetTag("line.number", lineNumber);

// Get blame for this line
var blameInfo = GitRangerPackage.BlameService?.GetBlameForLine(filePath, lineNumber);
if (blameInfo != null)
{
System.Windows.Clipboard.SetText(blameInfo.CommitSha);
activity?.SetTag("commit.sha", blameInfo.ShortSha);
VsixTelemetry.LogInformation("Copied commit SHA {CommitSha} to clipboard", blameInfo.ShortSha);
await VS.StatusBar.ShowMessageAsync($"Git Ranger: Copied commit SHA {blameInfo.ShortSha} to clipboard");
}
else
{
VsixTelemetry.LogInformation("No blame information available for line {LineNumber}", lineNumber);
await VS.StatusBar.ShowMessageAsync("Git Ranger: No blame information available for this line");
}
System.Windows.Clipboard.SetText(blameInfo.CommitSha);
activity?.SetTag("commit.sha", blameInfo.ShortSha);
VsixTelemetry.LogInformation("Copied commit SHA {CommitSha} to clipboard", blameInfo.ShortSha);
await VS.StatusBar.ShowMessageAsync($"Git Ranger: Copied commit SHA {blameInfo.ShortSha} to clipboard");
}
catch (Exception ex)
else
{
activity?.RecordError(ex);
VsixTelemetry.TrackException(ex, new Dictionary<string, object>
{
{ "operation.name", "CopyCommitSha" }
});
await VS.StatusBar.ShowMessageAsync($"Git Ranger: Error copying commit SHA - {ex.Message}");
VsixTelemetry.LogInformation("No blame information available for line {LineNumber}", lineNumber);
await VS.StatusBar.ShowMessageAsync("Git Ranger: No blame information available for this line");
}
}
catch (Exception ex)
{
activity?.RecordError(ex);
VsixTelemetry.TrackException(ex, new Dictionary<string, object>
{
{ "operation.name", "CopyCommitSha" }
});
await VS.StatusBar.ShowMessageAsync($"Git Ranger: Error copying commit SHA - {ex.Message}");
}
}
}
Loading