Skip to content

Commit 3d5f35d

Browse files
authored
feat(ui): add status bar blame and logging infrastructure (#67)
- Add StatusBarService to display blame info in VS status bar (#19) - Add configurable status bar format, relative dates, max length options - Add OutputPaneService with configurable log levels (None/Error/Info/Verbose) - Add LogLevel setting in Tools > Options > Git Ranger > Diagnostics (#66) - Refactor services to use interface-backed MEF exports - Upgrade LibGit2Sharp to 0.31.0 for SetOwnerValidation API - Disable owner validation to fix "not owned by current user" errors - Update CLAUDE.md with MEF service guidelines
1 parent e4208e2 commit 3d5f35d

22 files changed

Lines changed: 1619 additions & 1026 deletions

CLAUDE.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
1919
11. **Run validation before commits** - Run `dotnet build` and verify no errors before committing
2020
12. **No co-authors** - Do not add co-author information on commits or pull requests
2121
13. **No "generated by" statements** - Do not add generated-by statements on pull requests
22+
14. **MEF for services** - Follow MEF rules in VSIX Development Rules section below
23+
15. **No async void** - Do not use `async void`, use `async Task` instead
2224

2325
---
2426

@@ -46,6 +48,15 @@ gh issue close <number>
4648

4749
### VSIX Development Rules
4850

51+
**MEF (Managed Extensibility Framework) - REQUIRED:**
52+
53+
1. Services must be interface-backed and use MEF attributes appropriately (`[Export(typeof(IService))]`, `[ImportingConstructor]`, `[PartCreationPolicy]`)
54+
2. The package class should only retrieve from the component model what it absolutely needs to finish initialization - do not grab all services preemptively
55+
3. Services must NOT be exposed as global static properties on the package class
56+
4. MEF-composed classes must use a constructor flagged with `[ImportingConstructor]` with service dependencies as parameters
57+
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
58+
6. Prefer constructor injection over property injection
59+
4960
**Solution & Project Structure:**
5061
- SLNX solution files only (no legacy .sln)
5162
- Solution naming: `CodingWithCalvin.<ProjectFolder>`

src/CodingWithCalvin.GitRanger.Core/CodingWithCalvin.GitRanger.Core.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
</PropertyGroup>
1111

1212
<ItemGroup>
13-
<PackageReference Include="LibGit2Sharp" Version="0.30.0" />
13+
<PackageReference Include="LibGit2Sharp" Version="0.31.0" />
1414
<PackageReference Include="SkiaSharp" Version="2.88.7" />
1515
</ItemGroup>
1616

src/CodingWithCalvin.GitRanger/CodingWithCalvin.GitRanger.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
<PackageReference Include="Community.VisualStudio.Toolkit.17" Version="17.0.507" />
1515
<PackageReference Include="Microsoft.VisualStudio.SDK" Version="17.14.40265" ExcludeAssets="runtime" />
1616
<PackageReference Include="Microsoft.VSSDK.BuildTools" Version="17.*" PrivateAssets="all" />
17-
<PackageReference Include="LibGit2Sharp" Version="0.30.0" />
17+
<PackageReference Include="LibGit2Sharp" Version="0.31.0" />
1818
<PackageReference Include="SkiaSharp" Version="2.88.7" />
1919
<PackageReference Include="SkiaSharp.Views.WPF" Version="2.88.7" />
2020
</ItemGroup>

src/CodingWithCalvin.GitRanger/Commands/BlameCommands.cs

Lines changed: 144 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -3,174 +3,187 @@
33
using System.ComponentModel.Design;
44
using System.Threading.Tasks;
55
using CodingWithCalvin.GitRanger.Options;
6+
using CodingWithCalvin.GitRanger.Services;
67
using CodingWithCalvin.Otel4Vsix;
78
using Community.VisualStudio.Toolkit;
9+
using Microsoft.VisualStudio.ComponentModelHost;
810
using Microsoft.VisualStudio.Shell;
9-
using Task = System.Threading.Tasks.Task;
1011

11-
namespace CodingWithCalvin.GitRanger.Commands
12+
namespace CodingWithCalvin.GitRanger.Commands;
13+
14+
/// <summary>
15+
/// Commands related to blame functionality.
16+
/// </summary>
17+
internal static class BlameCommands
1218
{
13-
/// <summary>
14-
/// Commands related to blame functionality.
15-
/// </summary>
16-
internal static class BlameCommands
19+
private static IGitService? _gitService;
20+
private static IBlameService? _blameService;
21+
22+
public static async Task InitializeAsync(AsyncPackage package)
1723
{
18-
public static async Task InitializeAsync(AsyncPackage package)
24+
await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();
25+
26+
// Get services from MEF
27+
var componentModel = await package.GetServiceAsync(typeof(SComponentModel)) as IComponentModel;
28+
if (componentModel != null)
1929
{
20-
await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();
30+
_gitService = componentModel.GetService<IGitService>();
31+
_blameService = componentModel.GetService<IBlameService>();
32+
}
2133

22-
var commandService = await package.GetServiceAsync(typeof(IMenuCommandService)) as OleMenuCommandService;
23-
if (commandService == null)
24-
return;
34+
var commandService = await package.GetServiceAsync(typeof(IMenuCommandService)) as OleMenuCommandService;
35+
if (commandService == null)
36+
return;
37+
38+
// Toggle Inline Blame command
39+
var toggleInlineBlameId = new CommandID(VSCommandTableVsct.guidGitRangerPackageCmdSet.Guid, VSCommandTableVsct.guidGitRangerPackageCmdSet.cmdidToggleInlineBlame);
40+
var toggleInlineBlameCommand = new OleMenuCommand(OnToggleInlineBlame, toggleInlineBlameId);
41+
toggleInlineBlameCommand.BeforeQueryStatus += OnBeforeQueryStatusToggleInlineBlame;
42+
commandService.AddCommand(toggleInlineBlameCommand);
43+
44+
// Toggle Blame Gutter command
45+
var toggleBlameGutterId = new CommandID(VSCommandTableVsct.guidGitRangerPackageCmdSet.Guid, VSCommandTableVsct.guidGitRangerPackageCmdSet.cmdidToggleBlameGutter);
46+
var toggleBlameGutterCommand = new OleMenuCommand(OnToggleBlameGutter, toggleBlameGutterId);
47+
toggleBlameGutterCommand.BeforeQueryStatus += OnBeforeQueryStatusToggleBlameGutter;
48+
commandService.AddCommand(toggleBlameGutterCommand);
49+
50+
// Copy Commit SHA command
51+
var copyCommitShaId = new CommandID(VSCommandTableVsct.guidGitRangerPackageCmdSet.Guid, VSCommandTableVsct.guidGitRangerPackageCmdSet.cmdidCopyCommitSha);
52+
var copyCommitShaCommand = new OleMenuCommand(OnCopyCommitSha, copyCommitShaId);
53+
copyCommitShaCommand.BeforeQueryStatus += OnBeforeQueryStatusCopyCommitSha;
54+
commandService.AddCommand(copyCommitShaCommand);
55+
}
2556

26-
// Toggle Inline Blame command
27-
var toggleInlineBlameId = new CommandID(PackageGuids.guidGitRangerPackageCmdSet, PackageIds.cmdidToggleInlineBlame);
28-
var toggleInlineBlameCommand = new OleMenuCommand(OnToggleInlineBlame, toggleInlineBlameId);
29-
toggleInlineBlameCommand.BeforeQueryStatus += OnBeforeQueryStatusToggleInlineBlame;
30-
commandService.AddCommand(toggleInlineBlameCommand);
31-
32-
// Toggle Blame Gutter command
33-
var toggleBlameGutterId = new CommandID(PackageGuids.guidGitRangerPackageCmdSet, PackageIds.cmdidToggleBlameGutter);
34-
var toggleBlameGutterCommand = new OleMenuCommand(OnToggleBlameGutter, toggleBlameGutterId);
35-
toggleBlameGutterCommand.BeforeQueryStatus += OnBeforeQueryStatusToggleBlameGutter;
36-
commandService.AddCommand(toggleBlameGutterCommand);
37-
38-
// Copy Commit SHA command
39-
var copyCommitShaId = new CommandID(PackageGuids.guidGitRangerPackageCmdSet, PackageIds.cmdidCopyCommitSha);
40-
var copyCommitShaCommand = new OleMenuCommand(OnCopyCommitSha, copyCommitShaId);
41-
copyCommitShaCommand.BeforeQueryStatus += OnBeforeQueryStatusCopyCommitSha;
42-
commandService.AddCommand(copyCommitShaCommand);
43-
}
57+
private static void OnBeforeQueryStatusToggleInlineBlame(object sender, EventArgs e)
58+
{
59+
ThreadHelper.ThrowIfNotOnUIThread();
4460

45-
private static void OnBeforeQueryStatusToggleInlineBlame(object sender, EventArgs e)
61+
if (sender is OleMenuCommand command)
4662
{
47-
ThreadHelper.ThrowIfNotOnUIThread();
48-
49-
if (sender is OleMenuCommand command)
50-
{
51-
var options = GeneralOptions.Instance;
52-
var isEnabled = options?.EnableInlineBlame ?? true;
53-
command.Text = isEnabled
54-
? "Disable Inline Blame"
55-
: "Enable Inline Blame";
56-
command.Enabled = true;
57-
command.Visible = true;
58-
}
63+
var options = GeneralOptions.Instance;
64+
var isEnabled = options?.EnableInlineBlame ?? true;
65+
command.Text = isEnabled
66+
? "Disable Inline Blame"
67+
: "Enable Inline Blame";
68+
command.Enabled = true;
69+
command.Visible = true;
5970
}
71+
}
6072

61-
private static void OnToggleInlineBlame(object sender, EventArgs e)
73+
private static void OnToggleInlineBlame(object sender, EventArgs e)
74+
{
75+
ThreadHelper.ThrowIfNotOnUIThread();
76+
77+
using var activity = VsixTelemetry.StartCommandActivity("GitRanger.ToggleInlineBlame");
78+
79+
var options = GeneralOptions.Instance;
80+
if (options != null)
6281
{
63-
ThreadHelper.ThrowIfNotOnUIThread();
82+
options.EnableInlineBlame = !options.EnableInlineBlame;
83+
options.Save();
6484

65-
using var activity = VsixTelemetry.StartCommandActivity("GitRanger.ToggleInlineBlame");
85+
var status = options.EnableInlineBlame ? "enabled" : "disabled";
86+
activity?.SetTag("inline_blame.enabled", options.EnableInlineBlame);
87+
VsixTelemetry.LogInformation("Inline blame {Status}", status);
88+
VS.StatusBar.ShowMessageAsync($"Git Ranger: Inline blame {status}").FireAndForget();
89+
}
90+
}
6691

67-
var options = GeneralOptions.Instance;
68-
if (options != null)
69-
{
70-
options.EnableInlineBlame = !options.EnableInlineBlame;
71-
options.Save();
92+
private static void OnBeforeQueryStatusToggleBlameGutter(object sender, EventArgs e)
93+
{
94+
ThreadHelper.ThrowIfNotOnUIThread();
7295

73-
var status = options.EnableInlineBlame ? "enabled" : "disabled";
74-
activity?.SetTag("inline_blame.enabled", options.EnableInlineBlame);
75-
VsixTelemetry.LogInformation("Inline blame {Status}", status);
76-
VS.StatusBar.ShowMessageAsync($"Git Ranger: Inline blame {status}").FireAndForget();
77-
}
96+
if (sender is OleMenuCommand command)
97+
{
98+
var options = GeneralOptions.Instance;
99+
var isEnabled = options?.EnableBlameGutter ?? true;
100+
command.Text = isEnabled
101+
? "Disable Blame Gutter"
102+
: "Enable Blame Gutter";
103+
command.Enabled = true;
104+
command.Visible = true;
78105
}
106+
}
79107

80-
private static void OnBeforeQueryStatusToggleBlameGutter(object sender, EventArgs e)
108+
private static void OnToggleBlameGutter(object sender, EventArgs e)
109+
{
110+
ThreadHelper.ThrowIfNotOnUIThread();
111+
112+
using var activity = VsixTelemetry.StartCommandActivity("GitRanger.ToggleBlameGutter");
113+
114+
var options = GeneralOptions.Instance;
115+
if (options != null)
81116
{
82-
ThreadHelper.ThrowIfNotOnUIThread();
117+
options.EnableBlameGutter = !options.EnableBlameGutter;
118+
options.Save();
83119

84-
if (sender is OleMenuCommand command)
85-
{
86-
var options = GeneralOptions.Instance;
87-
var isEnabled = options?.EnableBlameGutter ?? true;
88-
command.Text = isEnabled
89-
? "Disable Blame Gutter"
90-
: "Enable Blame Gutter";
91-
command.Enabled = true;
92-
command.Visible = true;
93-
}
120+
var status = options.EnableBlameGutter ? "enabled" : "disabled";
121+
activity?.SetTag("blame_gutter.enabled", options.EnableBlameGutter);
122+
VsixTelemetry.LogInformation("Blame gutter {Status}", status);
123+
VS.StatusBar.ShowMessageAsync($"Git Ranger: Blame gutter {status}").FireAndForget();
94124
}
125+
}
126+
127+
private static void OnBeforeQueryStatusCopyCommitSha(object sender, EventArgs e)
128+
{
129+
ThreadHelper.ThrowIfNotOnUIThread();
95130

96-
private static void OnToggleBlameGutter(object sender, EventArgs e)
131+
if (sender is OleMenuCommand command)
97132
{
98-
ThreadHelper.ThrowIfNotOnUIThread();
133+
var isInRepo = _gitService?.IsInRepository ?? false;
134+
command.Enabled = isInRepo;
135+
command.Visible = true;
136+
}
137+
}
99138

100-
using var activity = VsixTelemetry.StartCommandActivity("GitRanger.ToggleBlameGutter");
139+
private static void OnCopyCommitSha(object sender, EventArgs e)
140+
{
141+
_ = OnCopyCommitShaAsync();
142+
}
101143

102-
var options = GeneralOptions.Instance;
103-
if (options != null)
104-
{
105-
options.EnableBlameGutter = !options.EnableBlameGutter;
106-
options.Save();
144+
private static async Task OnCopyCommitShaAsync()
145+
{
146+
await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();
107147

108-
var status = options.EnableBlameGutter ? "enabled" : "disabled";
109-
activity?.SetTag("blame_gutter.enabled", options.EnableBlameGutter);
110-
VsixTelemetry.LogInformation("Blame gutter {Status}", status);
111-
VS.StatusBar.ShowMessageAsync($"Git Ranger: Blame gutter {status}").FireAndForget();
112-
}
113-
}
148+
using var activity = VsixTelemetry.StartCommandActivity("GitRanger.CopyCommitSha");
114149

115-
private static void OnBeforeQueryStatusCopyCommitSha(object sender, EventArgs e)
150+
try
116151
{
117-
ThreadHelper.ThrowIfNotOnUIThread();
152+
var docView = await VS.Documents.GetActiveDocumentViewAsync();
153+
if (docView?.TextView == null)
154+
return;
118155

119-
if (sender is OleMenuCommand command)
120-
{
121-
// Only enable if we're in a git repository and have blame data
122-
var isInRepo = GitRangerPackage.GitService?.IsInRepository ?? false;
123-
command.Enabled = isInRepo;
124-
command.Visible = true;
125-
}
126-
}
156+
var filePath = docView.FilePath;
157+
if (string.IsNullOrEmpty(filePath))
158+
return;
127159

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

132-
using var activity = VsixTelemetry.StartCommandActivity("GitRanger.CopyCommitSha");
163+
activity?.SetTag("line.number", lineNumber);
133164

134-
try
165+
var blameInfo = _blameService?.GetBlameForLine(filePath, lineNumber);
166+
if (blameInfo != null)
135167
{
136-
var docView = await VS.Documents.GetActiveDocumentViewAsync();
137-
if (docView?.TextView == null)
138-
return;
139-
140-
var filePath = docView.FilePath;
141-
if (string.IsNullOrEmpty(filePath))
142-
return;
143-
144-
// Get the current line
145-
var caretPosition = docView.TextView.Caret.Position.BufferPosition;
146-
var lineNumber = docView.TextView.TextSnapshot.GetLineNumberFromPosition(caretPosition.Position) + 1;
147-
148-
activity?.SetTag("line.number", lineNumber);
149-
150-
// Get blame for this line
151-
var blameInfo = GitRangerPackage.BlameService?.GetBlameForLine(filePath, lineNumber);
152-
if (blameInfo != null)
153-
{
154-
System.Windows.Clipboard.SetText(blameInfo.CommitSha);
155-
activity?.SetTag("commit.sha", blameInfo.ShortSha);
156-
VsixTelemetry.LogInformation("Copied commit SHA {CommitSha} to clipboard", blameInfo.ShortSha);
157-
await VS.StatusBar.ShowMessageAsync($"Git Ranger: Copied commit SHA {blameInfo.ShortSha} to clipboard");
158-
}
159-
else
160-
{
161-
VsixTelemetry.LogInformation("No blame information available for line {LineNumber}", lineNumber);
162-
await VS.StatusBar.ShowMessageAsync("Git Ranger: No blame information available for this line");
163-
}
168+
System.Windows.Clipboard.SetText(blameInfo.CommitSha);
169+
activity?.SetTag("commit.sha", blameInfo.ShortSha);
170+
VsixTelemetry.LogInformation("Copied commit SHA {CommitSha} to clipboard", blameInfo.ShortSha);
171+
await VS.StatusBar.ShowMessageAsync($"Git Ranger: Copied commit SHA {blameInfo.ShortSha} to clipboard");
164172
}
165-
catch (Exception ex)
173+
else
166174
{
167-
activity?.RecordError(ex);
168-
VsixTelemetry.TrackException(ex, new Dictionary<string, object>
169-
{
170-
{ "operation.name", "CopyCommitSha" }
171-
});
172-
await VS.StatusBar.ShowMessageAsync($"Git Ranger: Error copying commit SHA - {ex.Message}");
175+
VsixTelemetry.LogInformation("No blame information available for line {LineNumber}", lineNumber);
176+
await VS.StatusBar.ShowMessageAsync("Git Ranger: No blame information available for this line");
173177
}
174178
}
179+
catch (Exception ex)
180+
{
181+
activity?.RecordError(ex);
182+
VsixTelemetry.TrackException(ex, new Dictionary<string, object>
183+
{
184+
{ "operation.name", "CopyCommitSha" }
185+
});
186+
await VS.StatusBar.ShowMessageAsync($"Git Ranger: Error copying commit SHA - {ex.Message}");
187+
}
175188
}
176189
}

0 commit comments

Comments
 (0)