From 57974b48f2a419efe5630aa581f071c6a561b840 Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Fri, 23 Jan 2026 16:49:17 -0500 Subject: [PATCH] feat(ui): add status bar blame and logging infrastructure - 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 --- CLAUDE.md | 11 + .../CodingWithCalvin.GitRanger.Core.csproj | 2 +- .../CodingWithCalvin.GitRanger.csproj | 2 +- .../Commands/BlameCommands.cs | 275 ++++---- .../Editor/BlameAdornment/BlameAdornment.cs | 577 +++++++--------- .../BlameAdornment/BlameAdornmentFactory.cs | 71 +- .../Editor/GutterMargin/BlameMargin.cs | 650 ++++++++---------- .../Editor/GutterMargin/BlameMarginFactory.cs | 67 +- .../GitRangerPackage.cs | 203 ++---- .../Options/GeneralOptions.cs | 58 ++ .../Options/GeneralOptionsPage.cs | 36 + .../Services/BlameService.cs | 21 +- .../Services/GitService.cs | 70 +- .../Services/IBlameService.cs | 75 ++ .../Services/IGitService.cs | 72 ++ .../Services/IOutputPaneService.cs | 44 ++ .../Services/IStatusBarService.cs | 11 + .../Services/IThemeService.cs | 76 ++ .../Services/OutputPaneService.cs | 126 ++++ .../Services/StatusBarService.cs | 187 +++++ .../Services/ThemeService.cs | 5 +- .../source.extension.vsixmanifest | 6 +- 22 files changed, 1619 insertions(+), 1026 deletions(-) create mode 100644 src/CodingWithCalvin.GitRanger/Services/IBlameService.cs create mode 100644 src/CodingWithCalvin.GitRanger/Services/IGitService.cs create mode 100644 src/CodingWithCalvin.GitRanger/Services/IOutputPaneService.cs create mode 100644 src/CodingWithCalvin.GitRanger/Services/IStatusBarService.cs create mode 100644 src/CodingWithCalvin.GitRanger/Services/IThemeService.cs create mode 100644 src/CodingWithCalvin.GitRanger/Services/OutputPaneService.cs create mode 100644 src/CodingWithCalvin.GitRanger/Services/StatusBarService.cs diff --git a/CLAUDE.md b/CLAUDE.md index 4a46788..a5d7688 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 --- @@ -46,6 +48,15 @@ gh issue close ### 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.` diff --git a/src/CodingWithCalvin.GitRanger.Core/CodingWithCalvin.GitRanger.Core.csproj b/src/CodingWithCalvin.GitRanger.Core/CodingWithCalvin.GitRanger.Core.csproj index 3557642..8e7fbc5 100644 --- a/src/CodingWithCalvin.GitRanger.Core/CodingWithCalvin.GitRanger.Core.csproj +++ b/src/CodingWithCalvin.GitRanger.Core/CodingWithCalvin.GitRanger.Core.csproj @@ -10,7 +10,7 @@ - + diff --git a/src/CodingWithCalvin.GitRanger/CodingWithCalvin.GitRanger.csproj b/src/CodingWithCalvin.GitRanger/CodingWithCalvin.GitRanger.csproj index fd205ce..572ddfe 100644 --- a/src/CodingWithCalvin.GitRanger/CodingWithCalvin.GitRanger.csproj +++ b/src/CodingWithCalvin.GitRanger/CodingWithCalvin.GitRanger.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/CodingWithCalvin.GitRanger/Commands/BlameCommands.cs b/src/CodingWithCalvin.GitRanger/Commands/BlameCommands.cs index 14479ca..e45fbff 100644 --- a/src/CodingWithCalvin.GitRanger/Commands/BlameCommands.cs +++ b/src/CodingWithCalvin.GitRanger/Commands/BlameCommands.cs @@ -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; + +/// +/// Commands related to blame functionality. +/// +internal static class BlameCommands { - /// - /// Commands related to blame functionality. - /// - 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(); + _blameService = componentModel.GetService(); + } - 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 - { - { "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 + { + { "operation.name", "CopyCommitSha" } + }); + await VS.StatusBar.ShowMessageAsync($"Git Ranger: Error copying commit SHA - {ex.Message}"); + } } } diff --git a/src/CodingWithCalvin.GitRanger/Editor/BlameAdornment/BlameAdornment.cs b/src/CodingWithCalvin.GitRanger/Editor/BlameAdornment/BlameAdornment.cs index 15de99e..4478d9c 100644 --- a/src/CodingWithCalvin.GitRanger/Editor/BlameAdornment/BlameAdornment.cs +++ b/src/CodingWithCalvin.GitRanger/Editor/BlameAdornment/BlameAdornment.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Windows; using System.Windows.Controls; @@ -13,399 +12,317 @@ using Microsoft.VisualStudio.Text.Editor; using Microsoft.VisualStudio.Text.Formatting; -namespace CodingWithCalvin.GitRanger.Editor.BlameAdornment +namespace CodingWithCalvin.GitRanger.Editor.BlameAdornment; + +/// +/// Adornment that renders blame information at the end of each line. +/// +internal sealed class BlameAdornment { /// - /// Adornment that renders blame information at the end of each line. + /// The name of the adornment layer. /// - internal sealed class BlameAdornment - { - /// - /// The name of the adornment layer. - /// - public const string LayerName = "GitRangerBlameAdornment"; - - private readonly IWpfTextView _view; - private readonly IAdornmentLayer _layer; - private readonly ITextDocumentFactoryService? _textDocumentFactoryService; - private readonly Dictionary _adornments = new Dictionary(); - private IReadOnlyList _blameData = Array.Empty(); - private string? _currentFilePath; - private bool _isLoading; - - /// - /// Creates a new blame adornment for the given text view. - /// - public BlameAdornment(IWpfTextView view, ITextDocumentFactoryService? textDocumentFactoryService) - { - Debug.WriteLine("[GitRanger] BlameAdornment constructor called"); - - _view = view ?? throw new ArgumentNullException(nameof(view)); - _textDocumentFactoryService = textDocumentFactoryService; - _layer = view.GetAdornmentLayer(LayerName); + public const string LayerName = "GitRangerBlameAdornment"; + + private readonly IWpfTextView _view; + private readonly IAdornmentLayer _layer; + private readonly ITextDocumentFactoryService _textDocumentFactoryService; + private readonly IGitService _gitService; + private readonly IBlameService _blameService; + private readonly IThemeService _themeService; + private readonly IOutputPaneService _outputPane; + private readonly Dictionary _adornments = new(); + private IReadOnlyList _blameData = Array.Empty(); + private string? _currentFilePath; + private bool _isLoading; - Debug.WriteLine($"[GitRanger] BlameAdornment - TextDocumentFactoryService: {(_textDocumentFactoryService == null ? "NULL" : "OK")}"); - Debug.WriteLine($"[GitRanger] BlameAdornment - AdornmentLayer: {(_layer == null ? "NULL" : "OK")}"); - - // Ensure services are initialized (in case package hasn't loaded yet) - GitRangerPackage.EnsureServicesInitialized(); - Debug.WriteLine("[GitRanger] BlameAdornment - Services initialized"); - - // Subscribe to events - _view.LayoutChanged += OnLayoutChanged; - _view.Closed += OnViewClosed; - - // Subscribe to blame service events - if (GitRangerPackage.BlameService != null) - { - GitRangerPackage.BlameService.BlameLoaded += OnBlameLoaded; - } - - // Subscribe to options changes - GeneralOptions.Saved += OnOptionsSaved; + /// + /// Creates a new blame adornment for the given text view. + /// + public BlameAdornment( + IWpfTextView view, + ITextDocumentFactoryService textDocumentFactoryService, + IGitService gitService, + IBlameService blameService, + IThemeService themeService, + IOutputPaneService outputPane) + { + _view = view ?? throw new ArgumentNullException(nameof(view)); + _textDocumentFactoryService = textDocumentFactoryService; + _gitService = gitService; + _blameService = blameService; + _themeService = themeService; + _outputPane = outputPane; + _layer = view.GetAdornmentLayer(LayerName); + + _outputPane.WriteVerbose("BlameAdornment created"); + + // Subscribe to events + _view.LayoutChanged += OnLayoutChanged; + _view.Closed += OnViewClosed; + _blameService.BlameLoaded += OnBlameLoaded; + GeneralOptions.Saved += OnOptionsSaved; + + // Initial load + LoadBlameData(); + } - // Initial load - LoadBlameDataAsync(); - } + private void OnViewClosed(object sender, EventArgs e) + { + _view.LayoutChanged -= OnLayoutChanged; + _view.Closed -= OnViewClosed; + _blameService.BlameLoaded -= OnBlameLoaded; + GeneralOptions.Saved -= OnOptionsSaved; + } - private void OnViewClosed(object sender, EventArgs e) + private void OnOptionsSaved(GeneralOptions options) + { + _ = ThreadHelper.JoinableTaskFactory.RunAsync(async () => { - _view.LayoutChanged -= OnLayoutChanged; - _view.Closed -= OnViewClosed; - - if (GitRangerPackage.BlameService != null) - { - GitRangerPackage.BlameService.BlameLoaded -= OnBlameLoaded; - } - - GeneralOptions.Saved -= OnOptionsSaved; - } + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + ClearAdornments(); + UpdateAdornments(); + }); + } - private void OnOptionsSaved(GeneralOptions options) + private void OnBlameLoaded(object? sender, BlameLoadedEventArgs e) + { + if (string.Equals(e.FilePath, _currentFilePath, StringComparison.OrdinalIgnoreCase)) { - // Refresh adornments when options change - ThreadHelper.JoinableTaskFactory.RunAsync(async () => + _ = ThreadHelper.JoinableTaskFactory.RunAsync(async () => { await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + _blameData = e.Lines; + _isLoading = false; + _outputPane.WriteVerbose("BlameAdornment received {0} lines", e.Lines.Count); ClearAdornments(); UpdateAdornments(); }); } + } - private void OnBlameLoaded(object? sender, BlameLoadedEventArgs e) - { - Debug.WriteLine($"[GitRanger] OnBlameLoaded - Event FilePath: {e.FilePath}, CurrentFilePath: {_currentFilePath}, LineCount: {e.Lines.Count}"); - - if (string.Equals(e.FilePath, _currentFilePath, StringComparison.OrdinalIgnoreCase)) - { - Debug.WriteLine("[GitRanger] OnBlameLoaded - File paths match, updating adornments"); - ThreadHelper.JoinableTaskFactory.RunAsync(async () => - { - await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); - _blameData = e.Lines; - _isLoading = false; - Debug.WriteLine($"[GitRanger] OnBlameLoaded - Blame data set, {_blameData.Count} lines"); - ClearAdornments(); - UpdateAdornments(); - }); - } - } - - private void LoadBlameDataAsync() - { - var filePath = GetFilePath(); - Debug.WriteLine($"[GitRanger] LoadBlameDataAsync - FilePath: {filePath ?? "NULL"}"); - - if (string.IsNullOrEmpty(filePath)) - { - Debug.WriteLine("[GitRanger] LoadBlameDataAsync - FilePath is null or empty, returning"); - return; - } - - if (string.Equals(filePath, _currentFilePath, StringComparison.OrdinalIgnoreCase)) - { - Debug.WriteLine("[GitRanger] LoadBlameDataAsync - Same file path, already loaded"); - return; - } + private void LoadBlameData() + { + var filePath = GetFilePath(); + if (string.IsNullOrEmpty(filePath)) + return; - _currentFilePath = filePath; - _isLoading = true; + if (string.Equals(filePath, _currentFilePath, StringComparison.OrdinalIgnoreCase)) + return; - // Try to open repository - var gitService = GitRangerPackage.GitService; - if (gitService == null) - { - Debug.WriteLine("[GitRanger] LoadBlameDataAsync - GitService is NULL"); - _isLoading = false; - return; - } + _currentFilePath = filePath; + _isLoading = true; - var repoOpened = gitService.TryOpenRepository(filePath); - Debug.WriteLine($"[GitRanger] LoadBlameDataAsync - TryOpenRepository: {repoOpened}, RepoPath: {gitService.CurrentRepositoryPath ?? "NULL"}"); + if (!_gitService.TryOpenRepository(filePath)) + { + _isLoading = false; + return; + } - if (!repoOpened) - { - Debug.WriteLine("[GitRanger] LoadBlameDataAsync - Failed to open repository"); - _isLoading = false; - return; - } + _blameService.LoadBlameInBackground(filePath); + } - // Load blame in background - var blameService = GitRangerPackage.BlameService; - if (blameService != null) - { - Debug.WriteLine("[GitRanger] LoadBlameDataAsync - Loading blame in background"); - blameService.LoadBlameInBackground(filePath); - } - else - { - Debug.WriteLine("[GitRanger] LoadBlameDataAsync - BlameService is NULL"); - _isLoading = false; - } + private string? GetFilePath() + { + if (_textDocumentFactoryService.TryGetTextDocument(_view.TextDataModel.DocumentBuffer, out var textDocument)) + { + return textDocument.FilePath; } - private string? GetFilePath() + if (_view.TextBuffer.Properties.TryGetProperty(typeof(ITextDocument), out ITextDocument doc)) { - // Try to get the file path using the document factory service - if (_textDocumentFactoryService != null) - { - if (_textDocumentFactoryService.TryGetTextDocument(_view.TextDataModel.DocumentBuffer, out var textDocument)) - { - return textDocument.FilePath; - } - } - - // Fallback: try to get from buffer properties - if (_view.TextBuffer.Properties.TryGetProperty(typeof(ITextDocument), out ITextDocument doc)) - { - return doc.FilePath; - } - - return null; + return doc.FilePath; } - private void OnLayoutChanged(object sender, TextViewLayoutChangedEventArgs e) - { - // Check if file path changed - var currentPath = GetFilePath(); - if (!string.Equals(currentPath, _currentFilePath, StringComparison.OrdinalIgnoreCase)) - { - _currentFilePath = null; - _blameData = Array.Empty(); - ClearAdornments(); - LoadBlameDataAsync(); - return; - } + return null; + } - // Update only for lines that changed or became visible - if (e.NewOrReformattedLines.Count > 0 || e.VerticalTranslation) - { - UpdateAdornments(); - } + private void OnLayoutChanged(object sender, TextViewLayoutChangedEventArgs e) + { + var currentPath = GetFilePath(); + if (!string.Equals(currentPath, _currentFilePath, StringComparison.OrdinalIgnoreCase)) + { + _currentFilePath = null; + _blameData = Array.Empty(); + ClearAdornments(); + LoadBlameData(); + return; } - private void ClearAdornments() + if (e.NewOrReformattedLines.Count > 0 || e.VerticalTranslation) { - _layer.RemoveAllAdornments(); - _adornments.Clear(); + UpdateAdornments(); } + } - private void UpdateAdornments() - { - Debug.WriteLine("[GitRanger] UpdateAdornments called"); + private void ClearAdornments() + { + _layer.RemoveAllAdornments(); + _adornments.Clear(); + } - var options = GeneralOptions.Instance; - Debug.WriteLine($"[GitRanger] UpdateAdornments - Options: {(options == null ? "NULL" : "OK")}, EnableInlineBlame: {options?.EnableInlineBlame}"); + private void UpdateAdornments() + { + var options = GeneralOptions.Instance; + if (options == null || !options.EnableInlineBlame) + { + ClearAdornments(); + return; + } - if (options == null || !options.EnableInlineBlame) - { - Debug.WriteLine("[GitRanger] UpdateAdornments - Options null or inline blame disabled"); - ClearAdornments(); - return; - } + if (_isLoading || _blameData.Count == 0) + return; - Debug.WriteLine($"[GitRanger] UpdateAdornments - IsLoading: {_isLoading}, BlameDataCount: {_blameData.Count}"); - if (_isLoading || _blameData.Count == 0) - return; + var viewportTop = _view.ViewportTop; + var viewportBottom = _view.ViewportBottom; - // Get visible lines - var viewportTop = _view.ViewportTop; - var viewportBottom = _view.ViewportBottom; + foreach (var line in _view.TextViewLines) + { + if (line.Bottom < viewportTop || line.Top > viewportBottom) + continue; - foreach (var line in _view.TextViewLines) - { - // Skip lines outside viewport - if (line.Bottom < viewportTop || line.Top > viewportBottom) - continue; + var lineNumber = _view.TextSnapshot.GetLineNumberFromPosition(line.Start.Position) + 1; + var blameInfo = _blameData.FirstOrDefault(b => b.LineNumber == lineNumber); + if (blameInfo == null) + continue; - var lineNumber = _view.TextSnapshot.GetLineNumberFromPosition(line.Start.Position) + 1; + CreateAdornmentForLine(line, blameInfo, options); + } + } - // Find blame data for this line - var blameInfo = _blameData.FirstOrDefault(b => b.LineNumber == lineNumber); - if (blameInfo == null) - continue; + private void CreateAdornmentForLine(ITextViewLine line, BlameLineInfo blameInfo, GeneralOptions options) + { + var lineNumber = _view.TextSnapshot.GetLineNumberFromPosition(line.Start.Position) + 1; - // Create or update adornment - CreateAdornmentForLine(line, blameInfo, options); - } + if (_adornments.TryGetValue(lineNumber, out var existing)) + { + _layer.RemoveAdornment(existing); + _adornments.Remove(lineNumber); } - private void CreateAdornmentForLine(ITextViewLine line, BlameLineInfo blameInfo, GeneralOptions options) - { - var lineNumber = _view.TextSnapshot.GetLineNumberFromPosition(line.Start.Position) + 1; + var blameText = BuildBlameText(blameInfo, options); + if (string.IsNullOrEmpty(blameText)) + return; - // Remove existing adornment for this line - if (_adornments.TryGetValue(lineNumber, out var existing)) - { - _layer.RemoveAdornment(existing); - _adornments.Remove(lineNumber); - } - - // Build the blame text - var blameText = BuildBlameText(blameInfo, options); - if (string.IsNullOrEmpty(blameText)) - return; - - // Get theme colors - var themeService = GitRangerPackage.ThemeService; - var textColor = themeService?.GetBlameTextColor() ?? Colors.Gray; - var authorColor = themeService?.GetAuthorColor(blameInfo.AuthorEmail) ?? Colors.Gray; - - // Create the visual - var textBlock = new TextBlock - { - Text = blameText, - FontFamily = new FontFamily("Consolas"), - FontSize = 11, - Foreground = new SolidColorBrush(textColor), - Opacity = options.InlineBlameOpacity, - Margin = new Thickness(20, 0, 0, 0), - ToolTip = CreateTooltip(blameInfo) - }; - - // Apply color based on mode - switch (options.BlameColorMode) - { - case ColorMode.Author: - textBlock.Foreground = new SolidColorBrush( - themeService?.AdjustForTheme(authorColor) ?? authorColor); - break; - - case ColorMode.Age: - var ageColor = themeService?.GetAgeHeatMapColor(blameInfo.AgeDays, options.MaxAgeDays) ?? Colors.Gray; - textBlock.Foreground = new SolidColorBrush(ageColor); - break; - } - - // Position at end of line - Canvas.SetLeft(textBlock, line.TextRight + 20); - Canvas.SetTop(textBlock, line.TextTop); - - // Add to layer - _layer.AddAdornment( - AdornmentPositioningBehavior.TextRelative, - line.Extent, - null, - textBlock, - (tag, element) => _adornments.Remove(lineNumber)); - - _adornments[lineNumber] = textBlock; - } + var textColor = _themeService.GetBlameTextColor(); + var authorColor = _themeService.GetAuthorColor(blameInfo.AuthorEmail); - private static string BuildBlameText(BlameLineInfo blameInfo, GeneralOptions options) + var textBlock = new TextBlock { - var parts = new List(); + Text = blameText, + FontFamily = new FontFamily("Consolas"), + FontSize = 11, + Foreground = new SolidColorBrush(textColor), + Opacity = options.InlineBlameOpacity, + Margin = new Thickness(20, 0, 0, 0), + ToolTip = CreateTooltip(blameInfo) + }; + + switch (options.BlameColorMode) + { + case ColorMode.Author: + textBlock.Foreground = new SolidColorBrush(_themeService.AdjustForTheme(authorColor)); + break; + + case ColorMode.Age: + var ageColor = _themeService.GetAgeHeatMapColor(blameInfo.AgeDays, options.MaxAgeDays); + textBlock.Foreground = new SolidColorBrush(ageColor); + break; + } - if (options.ShowAuthorName) - { - parts.Add(TruncateAuthor(blameInfo.Author)); - } + Canvas.SetLeft(textBlock, line.TextRight + 20); + Canvas.SetTop(textBlock, line.TextTop); - if (options.ShowCommitDate) - { - var dateText = options.DateFormat.ToLowerInvariant() == "relative" - ? blameInfo.RelativeTime - : blameInfo.AuthorDate.ToString(options.DateFormat); - parts.Add(dateText); - } + _layer.AddAdornment( + AdornmentPositioningBehavior.TextRelative, + line.Extent, + null, + textBlock, + (tag, element) => _adornments.Remove(lineNumber)); - if (options.ShowCommitMessage && !options.CompactMode) - { - parts.Add(TruncateMessage(blameInfo.CommitMessage, 50)); - } + _adornments[lineNumber] = textBlock; + } - return string.Join(" | ", parts); - } + private static string BuildBlameText(BlameLineInfo blameInfo, GeneralOptions options) + { + var parts = new List(); - private static string TruncateAuthor(string author, int maxLength = 15) + if (options.ShowAuthorName) { - if (string.IsNullOrEmpty(author)) - return string.Empty; - - if (author.Length <= maxLength) - return author; + parts.Add(TruncateAuthor(blameInfo.Author)); + } - return author.Substring(0, maxLength - 1) + "…"; + if (options.ShowCommitDate) + { + var dateText = options.DateFormat.ToLowerInvariant() == "relative" + ? blameInfo.RelativeTime + : blameInfo.AuthorDate.ToString(options.DateFormat); + parts.Add(dateText); } - private static string TruncateMessage(string message, int maxLength) + if (options.ShowCommitMessage && !options.CompactMode) { - if (string.IsNullOrEmpty(message)) - return string.Empty; + parts.Add(TruncateMessage(blameInfo.CommitMessage, 50)); + } - // Take first line only - var firstLine = message.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? message; + return string.Join(" | ", parts); + } - if (firstLine.Length <= maxLength) - return firstLine; + private static string TruncateAuthor(string author, int maxLength = 15) + { + if (string.IsNullOrEmpty(author)) + return string.Empty; - return firstLine.Substring(0, maxLength - 1) + "…"; - } + return author.Length <= maxLength ? author : author.Substring(0, maxLength - 1) + "…"; + } - private static object CreateTooltip(BlameLineInfo blameInfo) - { - var tooltip = new StackPanel { Margin = new Thickness(4) }; + private static string TruncateMessage(string message, int maxLength) + { + if (string.IsNullOrEmpty(message)) + return string.Empty; - // Commit SHA - tooltip.Children.Add(new TextBlock - { - Text = $"Commit: {blameInfo.ShortSha}", - FontWeight = FontWeights.Bold - }); + var firstLine = message.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? message; + return firstLine.Length <= maxLength ? firstLine : firstLine.Substring(0, maxLength - 1) + "…"; + } - // Author - tooltip.Children.Add(new TextBlock - { - Text = $"Author: {blameInfo.Author} <{blameInfo.AuthorEmail}>", - Margin = new Thickness(0, 4, 0, 0) - }); + private static object CreateTooltip(BlameLineInfo blameInfo) + { + var tooltip = new StackPanel { Margin = new Thickness(4) }; - // Date - tooltip.Children.Add(new TextBlock - { - Text = $"Date: {blameInfo.AuthorDate:yyyy-MM-dd HH:mm:ss} ({blameInfo.RelativeTime})", - Margin = new Thickness(0, 2, 0, 0) - }); + tooltip.Children.Add(new TextBlock + { + Text = $"Commit: {blameInfo.ShortSha}", + FontWeight = FontWeights.Bold + }); - // Message - tooltip.Children.Add(new TextBlock - { - Text = blameInfo.FullCommitMessage, - TextWrapping = TextWrapping.Wrap, - MaxWidth = 400, - Margin = new Thickness(0, 8, 0, 0) - }); + tooltip.Children.Add(new TextBlock + { + Text = $"Author: {blameInfo.Author} <{blameInfo.AuthorEmail}>", + Margin = new Thickness(0, 4, 0, 0) + }); - // Instructions - tooltip.Children.Add(new TextBlock - { - Text = "Right-click for more options", - FontStyle = FontStyles.Italic, - Foreground = Brushes.Gray, - Margin = new Thickness(0, 8, 0, 0) - }); + tooltip.Children.Add(new TextBlock + { + Text = $"Date: {blameInfo.AuthorDate:yyyy-MM-dd HH:mm:ss} ({blameInfo.RelativeTime})", + Margin = new Thickness(0, 2, 0, 0) + }); - return tooltip; - } + tooltip.Children.Add(new TextBlock + { + Text = blameInfo.FullCommitMessage, + TextWrapping = TextWrapping.Wrap, + MaxWidth = 400, + Margin = new Thickness(0, 8, 0, 0) + }); + + tooltip.Children.Add(new TextBlock + { + Text = "Right-click for more options", + FontStyle = FontStyles.Italic, + Foreground = Brushes.Gray, + Margin = new Thickness(0, 8, 0, 0) + }); + + return tooltip; } } diff --git a/src/CodingWithCalvin.GitRanger/Editor/BlameAdornment/BlameAdornmentFactory.cs b/src/CodingWithCalvin.GitRanger/Editor/BlameAdornment/BlameAdornmentFactory.cs index e332d28..cd6e376 100644 --- a/src/CodingWithCalvin.GitRanger/Editor/BlameAdornment/BlameAdornmentFactory.cs +++ b/src/CodingWithCalvin.GitRanger/Editor/BlameAdornment/BlameAdornmentFactory.cs @@ -1,38 +1,61 @@ using System.ComponentModel.Composition; +using CodingWithCalvin.GitRanger.Services; using Microsoft.VisualStudio.Text; using Microsoft.VisualStudio.Text.Editor; using Microsoft.VisualStudio.Utilities; -namespace CodingWithCalvin.GitRanger.Editor.BlameAdornment +namespace CodingWithCalvin.GitRanger.Editor.BlameAdornment; + +/// +/// Factory for creating blame adornment instances for text views. +/// +[Export(typeof(IWpfTextViewCreationListener))] +[ContentType("text")] +[TextViewRole(PredefinedTextViewRoles.Document)] +internal sealed class BlameAdornmentFactory : IWpfTextViewCreationListener { /// - /// Factory for creating blame adornment instances for text views. + /// The adornment layer definition for blame annotations. /// - [Export(typeof(IWpfTextViewCreationListener))] - [ContentType("text")] - [TextViewRole(PredefinedTextViewRoles.Document)] - internal sealed class BlameAdornmentFactory : IWpfTextViewCreationListener + [Export(typeof(AdornmentLayerDefinition))] + [Name(BlameAdornment.LayerName)] + [Order(After = PredefinedAdornmentLayers.Text)] + public AdornmentLayerDefinition? EditorAdornmentLayer = null; + + private readonly ITextDocumentFactoryService _textDocumentFactoryService; + private readonly IGitService _gitService; + private readonly IBlameService _blameService; + private readonly IThemeService _themeService; + private readonly IOutputPaneService _outputPane; + + [ImportingConstructor] + public BlameAdornmentFactory( + ITextDocumentFactoryService textDocumentFactoryService, + IGitService gitService, + IBlameService blameService, + IThemeService themeService, + IOutputPaneService outputPane) { - /// - /// The adornment layer definition for blame annotations. - /// - [Export(typeof(AdornmentLayerDefinition))] - [Name(BlameAdornment.LayerName)] - [Order(After = PredefinedAdornmentLayers.Text)] - public AdornmentLayerDefinition? EditorAdornmentLayer = null; + _textDocumentFactoryService = textDocumentFactoryService; + _gitService = gitService; + _blameService = blameService; + _themeService = themeService; + _outputPane = outputPane; + + _outputPane.WriteInfo("BlameAdornmentFactory created"); + } - [Import] - internal ITextDocumentFactoryService? TextDocumentFactoryService { get; set; } + /// + /// Called when a text view is created. + /// + /// The text view. + public void TextViewCreated(IWpfTextView textView) + { + _outputPane.WriteVerbose("BlameAdornmentFactory.TextViewCreated called"); - /// - /// Called when a text view is created. - /// - /// The text view. - public void TextViewCreated(IWpfTextView textView) + textView.Properties.GetOrCreateSingletonProperty(() => { - // Create the adornment for this view - textView.Properties.GetOrCreateSingletonProperty(() => - new BlameAdornment(textView, TextDocumentFactoryService)); - } + return new BlameAdornment(textView, _textDocumentFactoryService, _gitService, _blameService, _themeService, _outputPane); + }); } } diff --git a/src/CodingWithCalvin.GitRanger/Editor/GutterMargin/BlameMargin.cs b/src/CodingWithCalvin.GitRanger/Editor/GutterMargin/BlameMargin.cs index cf8f72b..fce3c97 100644 --- a/src/CodingWithCalvin.GitRanger/Editor/GutterMargin/BlameMargin.cs +++ b/src/CodingWithCalvin.GitRanger/Editor/GutterMargin/BlameMargin.cs @@ -14,439 +14,399 @@ using Microsoft.VisualStudio.Text.Editor; using Microsoft.VisualStudio.Text.Formatting; -namespace CodingWithCalvin.GitRanger.Editor.GutterMargin +namespace CodingWithCalvin.GitRanger.Editor.GutterMargin; + +/// +/// Margin that displays blame information in the gutter. +/// +internal sealed class BlameMargin : Canvas, IWpfTextViewMargin { /// - /// Margin that displays blame information in the gutter. + /// The name of this margin. /// - internal sealed class BlameMargin : Canvas, IWpfTextViewMargin + public const string MarginName = "GitRangerBlameMargin"; + + private readonly IWpfTextView _view; + private readonly ITextDocumentFactoryService _textDocumentFactoryService; + private readonly IGitService _gitService; + private readonly IBlameService _blameService; + private readonly IThemeService _themeService; + private readonly IOutputPaneService _outputPane; + private readonly Popup _tooltipPopup; + private IReadOnlyList _blameData = Array.Empty(); + private string? _currentFilePath; + private bool _isLoading; + private bool _isDisposed; + private int _currentTooltipLine = -1; + + /// + /// Creates a new blame margin for the given text view. + /// + public BlameMargin( + IWpfTextView view, + ITextDocumentFactoryService textDocumentFactoryService, + IGitService gitService, + IBlameService blameService, + IThemeService themeService, + IOutputPaneService outputPane) { - /// - /// The name of this margin. - /// - public const string MarginName = "GitRangerBlameMargin"; - - private readonly IWpfTextView _view; - private readonly ITextDocumentFactoryService? _textDocumentFactoryService; - private readonly Popup _tooltipPopup; - private IReadOnlyList _blameData = Array.Empty(); - private string? _currentFilePath; - private bool _isLoading; - private bool _isDisposed; - private int _currentTooltipLine = -1; - - /// - /// Creates a new blame margin for the given text view. - /// - public BlameMargin(IWpfTextView view, ITextDocumentFactoryService? textDocumentFactoryService) + _view = view ?? throw new ArgumentNullException(nameof(view)); + _textDocumentFactoryService = textDocumentFactoryService; + _gitService = gitService; + _blameService = blameService; + _themeService = themeService; + _outputPane = outputPane; + + _outputPane.WriteVerbose("BlameMargin created"); + + var options = GeneralOptions.Instance; + Width = options?.GutterWidth ?? 40; + ClipToBounds = true; + Background = Brushes.Transparent; + + _tooltipPopup = new Popup { - _view = view ?? throw new ArgumentNullException(nameof(view)); - _textDocumentFactoryService = textDocumentFactoryService; + AllowsTransparency = true, + Placement = PlacementMode.Mouse, + StaysOpen = true, + IsHitTestVisible = false, + PopupAnimation = PopupAnimation.None + }; + + _view.LayoutChanged += OnLayoutChanged; + _view.Closed += OnViewClosed; + _blameService.BlameLoaded += OnBlameLoaded; + GeneralOptions.Saved += OnOptionsSaved; + + PreviewMouseLeftButtonDown += OnMouseLeftButtonDown; + MouseMove += OnMouseMove; + MouseLeave += OnMouseLeave; + + LoadBlameData(); + } + + #region IWpfTextViewMargin Members + + public FrameworkElement VisualElement => this; - // Set initial size + public double MarginSize => Width; + + public bool Enabled + { + get + { var options = GeneralOptions.Instance; - Width = options?.GutterWidth ?? 40; - ClipToBounds = true; - Background = Brushes.Transparent; // Required for mouse events to work + return options?.EnableBlameGutter ?? true; + } + } - // Create custom tooltip popup (WPF ToolTip doesn't update well dynamically) - _tooltipPopup = new Popup - { - AllowsTransparency = true, - Placement = PlacementMode.Mouse, - StaysOpen = true, - IsHitTestVisible = false, - PopupAnimation = PopupAnimation.None - }; - - // Ensure services are initialized (in case package hasn't loaded yet) - GitRangerPackage.EnsureServicesInitialized(); - - // Subscribe to events - _view.LayoutChanged += OnLayoutChanged; - _view.Closed += OnViewClosed; - - // Subscribe to blame service events - if (GitRangerPackage.BlameService != null) - { - GitRangerPackage.BlameService.BlameLoaded += OnBlameLoaded; - } + public ITextViewMargin? GetTextViewMargin(string marginName) + { + return marginName == MarginName ? this : null; + } - // Subscribe to options changes - GeneralOptions.Saved += OnOptionsSaved; + public void Dispose() + { + if (_isDisposed) + return; - // Handle mouse events (use Preview to ensure we get clicks even with popup open) - PreviewMouseLeftButtonDown += OnMouseLeftButtonDown; - MouseMove += OnMouseMove; - MouseLeave += OnMouseLeave; + _isDisposed = true; - // Initial load - LoadBlameDataAsync(); - } + _tooltipPopup.IsOpen = false; - #region IWpfTextViewMargin Members + _view.LayoutChanged -= OnLayoutChanged; + _view.Closed -= OnViewClosed; + _blameService.BlameLoaded -= OnBlameLoaded; + GeneralOptions.Saved -= OnOptionsSaved; + } - public FrameworkElement VisualElement => this; + #endregion - public double MarginSize => Width; + private void OnViewClosed(object sender, EventArgs e) => Dispose(); - public bool Enabled + private void OnOptionsSaved(GeneralOptions options) + { + _ = ThreadHelper.JoinableTaskFactory.RunAsync(async () => { - get - { - var options = GeneralOptions.Instance; - return options?.EnableBlameGutter ?? true; - } - } + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + Width = options.GutterWidth; + InvalidateVisual(); + }); + } - public ITextViewMargin? GetTextViewMargin(string marginName) + private void OnBlameLoaded(object? sender, BlameLoadedEventArgs e) + { + if (string.Equals(e.FilePath, _currentFilePath, StringComparison.OrdinalIgnoreCase)) { - return marginName == MarginName ? this : null; + _ = ThreadHelper.JoinableTaskFactory.RunAsync(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + _blameData = e.Lines; + _isLoading = false; + InvalidateVisual(); + }); } + } - public void Dispose() - { - if (_isDisposed) - return; - - _isDisposed = true; - - _tooltipPopup.IsOpen = false; + private void LoadBlameData() + { + var filePath = GetFilePath(); + if (string.IsNullOrEmpty(filePath)) + return; - _view.LayoutChanged -= OnLayoutChanged; - _view.Closed -= OnViewClosed; + if (string.Equals(filePath, _currentFilePath, StringComparison.OrdinalIgnoreCase)) + return; - if (GitRangerPackage.BlameService != null) - { - GitRangerPackage.BlameService.BlameLoaded -= OnBlameLoaded; - } + _currentFilePath = filePath; + _isLoading = true; - GeneralOptions.Saved -= OnOptionsSaved; + if (!_gitService.TryOpenRepository(filePath)) + { + _isLoading = false; + return; } - #endregion + _blameService.LoadBlameInBackground(filePath); + } - private void OnViewClosed(object sender, EventArgs e) + private string? GetFilePath() + { + if (_textDocumentFactoryService.TryGetTextDocument(_view.TextDataModel.DocumentBuffer, out var textDocument)) { - Dispose(); + return textDocument.FilePath; } - private void OnOptionsSaved(GeneralOptions options) + if (_view.TextBuffer.Properties.TryGetProperty(typeof(ITextDocument), out ITextDocument doc)) { - ThreadHelper.JoinableTaskFactory.RunAsync(async () => - { - await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); - Width = options.GutterWidth; - InvalidateVisual(); - }); + return doc.FilePath; } - private void OnBlameLoaded(object? sender, BlameLoadedEventArgs e) + return null; + } + + private void OnLayoutChanged(object sender, TextViewLayoutChangedEventArgs e) + { + var currentPath = GetFilePath(); + if (!string.Equals(currentPath, _currentFilePath, StringComparison.OrdinalIgnoreCase)) { - if (string.Equals(e.FilePath, _currentFilePath, StringComparison.OrdinalIgnoreCase)) - { - ThreadHelper.JoinableTaskFactory.RunAsync(async () => - { - await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); - _blameData = e.Lines; - _isLoading = false; - InvalidateVisual(); - }); - } + _currentFilePath = null; + _blameData = Array.Empty(); + LoadBlameData(); } - private void LoadBlameDataAsync() - { - var filePath = GetFilePath(); - if (string.IsNullOrEmpty(filePath)) - return; + InvalidateVisual(); + } - if (string.Equals(filePath, _currentFilePath, StringComparison.OrdinalIgnoreCase)) - return; + protected override void OnRender(DrawingContext drawingContext) + { + base.OnRender(drawingContext); - _currentFilePath = filePath; - _isLoading = true; + var options = GeneralOptions.Instance; + if (options == null || !options.EnableBlameGutter) + return; - var gitService = GitRangerPackage.GitService; - if (gitService == null) - { - _isLoading = false; - return; - } + if (_isLoading || _blameData.Count == 0) + return; - if (!gitService.TryOpenRepository(filePath)) - { - _isLoading = false; - return; - } + var backgroundColor = _themeService.GetBlameBackgroundColor(); - var blameService = GitRangerPackage.BlameService; - if (blameService != null) - { - blameService.LoadBlameInBackground(filePath); - } - else - { - _isLoading = false; - } - } + drawingContext.DrawRectangle( + new SolidColorBrush(backgroundColor), + null, + new Rect(0, 0, ActualWidth, ActualHeight)); - private string? GetFilePath() + foreach (var line in _view.TextViewLines) { - // Try to get the file path using the document factory service - if (_textDocumentFactoryService != null) - { - if (_textDocumentFactoryService.TryGetTextDocument(_view.TextDataModel.DocumentBuffer, out var textDocument)) - { - return textDocument.FilePath; - } - } + if (line.VisibilityState == VisibilityState.Unattached) + continue; - // Fallback: try to get from buffer properties - if (_view.TextBuffer.Properties.TryGetProperty(typeof(ITextDocument), out ITextDocument doc)) - { - return doc.FilePath; - } + var lineNumber = _view.TextSnapshot.GetLineNumberFromPosition(line.Start.Position) + 1; + var blameInfo = _blameData.FirstOrDefault(b => b.LineNumber == lineNumber); + if (blameInfo == null) + continue; - return null; + DrawLineIndicator(drawingContext, line, blameInfo, options); } + } - private void OnLayoutChanged(object sender, TextViewLayoutChangedEventArgs e) - { - var currentPath = GetFilePath(); - if (!string.Equals(currentPath, _currentFilePath, StringComparison.OrdinalIgnoreCase)) - { - _currentFilePath = null; - _blameData = Array.Empty(); - LoadBlameDataAsync(); - } + private void DrawLineIndicator( + DrawingContext drawingContext, + ITextViewLine line, + BlameLineInfo blameInfo, + GeneralOptions options) + { + var y = line.TextTop - _view.ViewportTop; + var height = line.TextHeight; - InvalidateVisual(); + Color color; + switch (options.BlameColorMode) + { + case ColorMode.Author: + color = _themeService.GetAuthorColor(blameInfo.AuthorEmail); + color = _themeService.AdjustForTheme(color); + break; + + case ColorMode.Age: + color = _themeService.GetAgeHeatMapColor(blameInfo.AgeDays, options.MaxAgeDays); + break; + + default: + color = Colors.Gray; + break; } - protected override void OnRender(DrawingContext drawingContext) + if (options.ShowAgeBars) { - base.OnRender(drawingContext); - - var options = GeneralOptions.Instance; - if (options == null || !options.EnableBlameGutter) - return; + var maxWidth = ActualWidth - 4; + var ageRatio = Math.Min(1.0, (double)blameInfo.AgeDays / options.MaxAgeDays); + var barWidth = maxWidth * (1.0 - ageRatio); - if (_isLoading || _blameData.Count == 0) - return; + var brush = new SolidColorBrush(color); + brush.Freeze(); - var themeService = GitRangerPackage.ThemeService; - var backgroundColor = themeService?.GetBlameBackgroundColor() ?? Color.FromArgb(30, 0, 0, 0); - - // Draw background drawingContext.DrawRectangle( - new SolidColorBrush(backgroundColor), + brush, null, - new Rect(0, 0, ActualWidth, ActualHeight)); - - // Draw for each visible line - foreach (var line in _view.TextViewLines) - { - if (line.VisibilityState == VisibilityState.Unattached) - continue; - - var lineNumber = _view.TextSnapshot.GetLineNumberFromPosition(line.Start.Position) + 1; - var blameInfo = _blameData.FirstOrDefault(b => b.LineNumber == lineNumber); - if (blameInfo == null) - continue; - - DrawLineIndicator(drawingContext, line, blameInfo, options, themeService); - } + new Rect(2, y + 2, barWidth, height - 4)); } - - private void DrawLineIndicator( - DrawingContext drawingContext, - ITextViewLine line, - BlameLineInfo blameInfo, - GeneralOptions options, - ThemeService? themeService) + else { - var y = line.TextTop - _view.ViewportTop; - var height = line.TextHeight; - - // Get color based on mode - Color color; - switch (options.BlameColorMode) - { - case ColorMode.Author: - color = themeService?.GetAuthorColor(blameInfo.AuthorEmail) ?? Colors.Gray; - color = themeService?.AdjustForTheme(color) ?? color; - break; - - case ColorMode.Age: - color = themeService?.GetAgeHeatMapColor(blameInfo.AgeDays, options.MaxAgeDays) ?? Colors.Gray; - break; - - default: - color = Colors.Gray; - break; - } + var brush = new SolidColorBrush(color); + brush.Freeze(); - if (options.ShowAgeBars) - { - // Draw age bar (width proportional to age) - var maxWidth = ActualWidth - 4; - var ageRatio = Math.Min(1.0, (double)blameInfo.AgeDays / options.MaxAgeDays); - var barWidth = maxWidth * (1.0 - ageRatio); // Newer = longer bar - - var brush = new SolidColorBrush(color); - brush.Freeze(); - - drawingContext.DrawRectangle( - brush, - null, - new Rect(2, y + 2, barWidth, height - 4)); - } - else - { - // Draw simple color indicator - var brush = new SolidColorBrush(color); - brush.Freeze(); - - drawingContext.DrawRectangle( - brush, - null, - new Rect(2, y + 2, 4, height - 4)); - } + drawingContext.DrawRectangle( + brush, + null, + new Rect(2, y + 2, 4, height - 4)); } + } - private void OnMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + private void OnMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (_currentTooltipLine > 0) { - // Use the current tooltip line since we already know it has blame data - if (_currentTooltipLine > 0) + var blameInfo = _blameData.FirstOrDefault(b => b.LineNumber == _currentTooltipLine); + if (blameInfo != null) { - var blameInfo = _blameData.FirstOrDefault(b => b.LineNumber == _currentTooltipLine); - if (blameInfo != null) + System.Windows.Clipboard.SetText(blameInfo.CommitSha); + _ = ThreadHelper.JoinableTaskFactory.RunAsync(async () => { - System.Windows.Clipboard.SetText(blameInfo.CommitSha); - ThreadHelper.JoinableTaskFactory.RunAsync(async () => - { - await Community.VisualStudio.Toolkit.VS.StatusBar.ShowMessageAsync( - $"Git Ranger: Copied commit SHA {blameInfo.ShortSha} to clipboard"); - }); - e.Handled = true; - } + await Community.VisualStudio.Toolkit.VS.StatusBar.ShowMessageAsync( + $"Git Ranger: Copied commit SHA {blameInfo.ShortSha} to clipboard"); + }); + e.Handled = true; } } + } - private void OnMouseMove(object sender, MouseEventArgs e) - { - var position = e.GetPosition(this); - var blameInfo = GetBlameInfoAtPosition(position); + private void OnMouseMove(object sender, MouseEventArgs e) + { + var position = e.GetPosition(this); + var blameInfo = GetBlameInfoAtPosition(position); - if (blameInfo != null) - { - Cursor = Cursors.Hand; + if (blameInfo != null) + { + Cursor = Cursors.Hand; - // Only update popup if line changed - if (_currentTooltipLine != blameInfo.LineNumber) - { - _currentTooltipLine = blameInfo.LineNumber; - _tooltipPopup.Child = CreateTooltip(blameInfo); - _tooltipPopup.IsOpen = true; - } - } - else + if (_currentTooltipLine != blameInfo.LineNumber) { - Cursor = Cursors.Arrow; - if (_currentTooltipLine != -1) - { - _currentTooltipLine = -1; - _tooltipPopup.IsOpen = false; - } + _currentTooltipLine = blameInfo.LineNumber; + _tooltipPopup.Child = CreateTooltip(blameInfo); + _tooltipPopup.IsOpen = true; } } - - private void OnMouseLeave(object sender, MouseEventArgs e) + else { Cursor = Cursors.Arrow; - _currentTooltipLine = -1; - _tooltipPopup.IsOpen = false; + if (_currentTooltipLine != -1) + { + _currentTooltipLine = -1; + _tooltipPopup.IsOpen = false; + } } + } + + private void OnMouseLeave(object sender, MouseEventArgs e) + { + Cursor = Cursors.Arrow; + _currentTooltipLine = -1; + _tooltipPopup.IsOpen = false; + } - private BlameLineInfo? GetBlameInfoAtPosition(Point position) + private BlameLineInfo? GetBlameInfoAtPosition(Point position) + { + var viewY = position.Y + _view.ViewportTop; + + foreach (var line in _view.TextViewLines) { - // Adjust for scroll position - var viewY = position.Y + _view.ViewportTop; + if (line.VisibilityState == VisibilityState.Unattached) + continue; - foreach (var line in _view.TextViewLines) + if (viewY >= line.TextTop && viewY <= line.TextBottom) { - if (line.VisibilityState == VisibilityState.Unattached) - continue; - - if (viewY >= line.TextTop && viewY <= line.TextBottom) - { - var lineNumber = _view.TextSnapshot.GetLineNumberFromPosition(line.Start.Position) + 1; - return _blameData.FirstOrDefault(b => b.LineNumber == lineNumber); - } + var lineNumber = _view.TextSnapshot.GetLineNumberFromPosition(line.Start.Position) + 1; + return _blameData.FirstOrDefault(b => b.LineNumber == lineNumber); } - - return null; } - private static UIElement CreateTooltip(BlameLineInfo blameInfo) - { - var content = new StackPanel { Margin = new Thickness(8) }; + return null; + } - content.Children.Add(new TextBlock - { - Text = $"Commit: {blameInfo.ShortSha}", - FontWeight = FontWeights.Bold - }); + private static UIElement CreateTooltip(BlameLineInfo blameInfo) + { + var content = new StackPanel { Margin = new Thickness(8) }; - content.Children.Add(new TextBlock - { - Text = $"Author: {blameInfo.Author}", - Margin = new Thickness(0, 4, 0, 0) - }); + content.Children.Add(new TextBlock + { + Text = $"Commit: {blameInfo.ShortSha}", + FontWeight = FontWeights.Bold + }); - content.Children.Add(new TextBlock - { - Text = $"Date: {blameInfo.RelativeTime}", - Margin = new Thickness(0, 2, 0, 0) - }); + content.Children.Add(new TextBlock + { + Text = $"Author: {blameInfo.Author}", + Margin = new Thickness(0, 4, 0, 0) + }); - content.Children.Add(new TextBlock - { - Text = blameInfo.CommitMessage, - TextWrapping = TextWrapping.Wrap, - MaxWidth = 300, - Margin = new Thickness(0, 8, 0, 0) - }); + content.Children.Add(new TextBlock + { + Text = $"Date: {blameInfo.RelativeTime}", + Margin = new Thickness(0, 2, 0, 0) + }); - content.Children.Add(new TextBlock - { - Text = "Click to copy commit SHA", - FontStyle = FontStyles.Italic, - Foreground = Brushes.Gray, - Margin = new Thickness(0, 8, 0, 0) - }); + content.Children.Add(new TextBlock + { + Text = blameInfo.CommitMessage, + TextWrapping = TextWrapping.Wrap, + MaxWidth = 300, + Margin = new Thickness(0, 8, 0, 0) + }); - // Wrap in a border for tooltip appearance - var border = new Border - { - Background = new SolidColorBrush(Color.FromRgb(45, 45, 48)), - BorderBrush = new SolidColorBrush(Color.FromRgb(63, 63, 70)), - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(3), - Child = content - }; - - // Set text color for dark background - foreach (var child in content.Children) + content.Children.Add(new TextBlock + { + Text = "Click to copy commit SHA", + FontStyle = FontStyles.Italic, + Foreground = Brushes.Gray, + Margin = new Thickness(0, 8, 0, 0) + }); + + var border = new Border + { + Background = new SolidColorBrush(Color.FromRgb(45, 45, 48)), + BorderBrush = new SolidColorBrush(Color.FromRgb(63, 63, 70)), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(3), + Child = content + }; + + foreach (var child in content.Children) + { + if (child is TextBlock textBlock && textBlock.Foreground != Brushes.Gray) { - if (child is TextBlock textBlock && textBlock.Foreground != Brushes.Gray) - { - textBlock.Foreground = Brushes.White; - } + textBlock.Foreground = Brushes.White; } - - return border; } + + return border; } } diff --git a/src/CodingWithCalvin.GitRanger/Editor/GutterMargin/BlameMarginFactory.cs b/src/CodingWithCalvin.GitRanger/Editor/GutterMargin/BlameMarginFactory.cs index 992a67d..8d84a72 100644 --- a/src/CodingWithCalvin.GitRanger/Editor/GutterMargin/BlameMarginFactory.cs +++ b/src/CodingWithCalvin.GitRanger/Editor/GutterMargin/BlameMarginFactory.cs @@ -1,33 +1,58 @@ using System.ComponentModel.Composition; +using CodingWithCalvin.GitRanger.Services; using Microsoft.VisualStudio.Text; using Microsoft.VisualStudio.Text.Editor; using Microsoft.VisualStudio.Utilities; -namespace CodingWithCalvin.GitRanger.Editor.GutterMargin +namespace CodingWithCalvin.GitRanger.Editor.GutterMargin; + +/// +/// Factory for creating blame margin instances. +/// +[Export(typeof(IWpfTextViewMarginProvider))] +[Name(BlameMargin.MarginName)] +[Order(Before = PredefinedMarginNames.LineNumber)] +[MarginContainer(PredefinedMarginNames.LeftSelection)] +[ContentType("text")] +[TextViewRole(PredefinedTextViewRoles.Document)] +internal sealed class BlameMarginFactory : IWpfTextViewMarginProvider { + private readonly ITextDocumentFactoryService _textDocumentFactoryService; + private readonly IGitService _gitService; + private readonly IBlameService _blameService; + private readonly IThemeService _themeService; + private readonly IOutputPaneService _outputPane; + + [ImportingConstructor] + public BlameMarginFactory( + ITextDocumentFactoryService textDocumentFactoryService, + IGitService gitService, + IBlameService blameService, + IThemeService themeService, + IOutputPaneService outputPane) + { + _textDocumentFactoryService = textDocumentFactoryService; + _gitService = gitService; + _blameService = blameService; + _themeService = themeService; + _outputPane = outputPane; + + _outputPane.WriteInfo("BlameMarginFactory created"); + } + /// - /// Factory for creating blame margin instances. + /// Creates the margin for the given text view. /// - [Export(typeof(IWpfTextViewMarginProvider))] - [Name(BlameMargin.MarginName)] - [Order(Before = PredefinedMarginNames.LineNumber)] - [MarginContainer(PredefinedMarginNames.LeftSelection)] - [ContentType("text")] - [TextViewRole(PredefinedTextViewRoles.Document)] - internal sealed class BlameMarginFactory : IWpfTextViewMarginProvider + public IWpfTextViewMargin? CreateMargin(IWpfTextViewHost wpfTextViewHost, IWpfTextViewMargin marginContainer) { - [Import] - internal ITextDocumentFactoryService? TextDocumentFactoryService { get; set; } + _outputPane.WriteVerbose("BlameMarginFactory.CreateMargin called"); - /// - /// Creates the margin for the given text view. - /// - /// The text view host. - /// The margin container. - /// The blame margin, or null if creation fails. - public IWpfTextViewMargin? CreateMargin(IWpfTextViewHost wpfTextViewHost, IWpfTextViewMargin marginContainer) - { - return new BlameMargin(wpfTextViewHost.TextView, TextDocumentFactoryService); - } + return new BlameMargin( + wpfTextViewHost.TextView, + _textDocumentFactoryService, + _gitService, + _blameService, + _themeService, + _outputPane); } } diff --git a/src/CodingWithCalvin.GitRanger/GitRangerPackage.cs b/src/CodingWithCalvin.GitRanger/GitRangerPackage.cs index fbc3c42..43b0413 100644 --- a/src/CodingWithCalvin.GitRanger/GitRangerPackage.cs +++ b/src/CodingWithCalvin.GitRanger/GitRangerPackage.cs @@ -1,186 +1,87 @@ using System; using System.Runtime.InteropServices; using System.Threading; -using System.Threading.Tasks; using CodingWithCalvin.GitRanger.Commands; using CodingWithCalvin.GitRanger.Options; using CodingWithCalvin.GitRanger.Services; using CodingWithCalvin.Otel4Vsix; using Community.VisualStudio.Toolkit; using Microsoft.VisualStudio; +using Microsoft.VisualStudio.ComponentModelHost; using Microsoft.VisualStudio.Shell; using Task = System.Threading.Tasks.Task; -namespace CodingWithCalvin.GitRanger +namespace CodingWithCalvin.GitRanger; + +/// +/// Git Ranger - A visually exciting Git management extension for Visual Studio. +/// +[PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)] +[InstalledProductRegistration(Vsix.Name, Vsix.Description, Vsix.Version)] +[ProvideMenuResource("Menus.ctmenu", 1)] +[Guid(VSCommandTableVsct.guidGitRangerPackageString)] +[ProvideAutoLoad(VSConstants.UICONTEXT.SolutionExists_string, PackageAutoLoadFlags.BackgroundLoad)] +[ProvideAutoLoad(VSConstants.UICONTEXT.FolderOpened_string, PackageAutoLoadFlags.BackgroundLoad)] +[ProvideAutoLoad(VSConstants.UICONTEXT.NoSolution_string, PackageAutoLoadFlags.BackgroundLoad)] +[ProvideOptionPage(typeof(GeneralOptionsPage), "Git Ranger", "General", 0, 0, true)] +public sealed class GitRangerPackage : ToolkitPackage { + private IStatusBarService? _statusBarService; + /// - /// Git Ranger - A visually exciting Git management extension for Visual Studio. + /// Initialization of the package; this method is called right after the package is sited. /// - [PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)] - [InstalledProductRegistration(Vsix.Name, Vsix.Description, Vsix.Version)] - [ProvideMenuResource("Menus.ctmenu", 1)] - [Guid(PackageGuids.guidGitRangerPackageString)] - [ProvideAutoLoad(VSConstants.UICONTEXT.SolutionExists_string, PackageAutoLoadFlags.BackgroundLoad)] - [ProvideAutoLoad(VSConstants.UICONTEXT.FolderOpened_string, PackageAutoLoadFlags.BackgroundLoad)] - [ProvideAutoLoad(VSConstants.UICONTEXT.NoSolution_string, PackageAutoLoadFlags.BackgroundLoad)] - [ProvideOptionPage(typeof(GeneralOptionsPage), "Git Ranger", "General", 0, 0, true)] - public sealed class GitRangerPackage : ToolkitPackage + protected override async Task InitializeAsync(CancellationToken cancellationToken, IProgress progress) { - private static readonly object _initLock = new object(); - private static bool _servicesInitialized = false; - - /// - /// The Git service instance for repository operations. - /// - public static GitService? GitService { get; private set; } - - /// - /// The Theme service instance for VS theme adaptation. - /// - public static ThemeService? ThemeService { get; private set; } - - /// - /// The Blame service instance for blame operations. - /// - public static BlameService? BlameService { get; private set; } - - /// - /// Ensures that services are initialized. Can be called from MEF components. - /// - public static void EnsureServicesInitialized() - { - if (_servicesInitialized) - return; - - lock (_initLock) - { - if (_servicesInitialized) - return; - - // Initialize services synchronously if not already done - if (ThemeService == null) - { - ThemeService = new ThemeService(); - // Note: InitializeAsync needs to run on UI thread, - // but we can still create the service - } - - if (GitService == null) - { - GitService = new GitService(); - } + await base.InitializeAsync(cancellationToken, progress); + await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); - if (BlameService == null && GitService != null && ThemeService != null) - { - BlameService = new BlameService(GitService, ThemeService); - } - - _servicesInitialized = true; - } - } - - /// - /// Initialization of the package; this method is called right after the package is sited. - /// - protected override async Task InitializeAsync(CancellationToken cancellationToken, IProgress progress) - { - await base.InitializeAsync(cancellationToken, progress); - - // Switch to the main thread for telemetry initialization - await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); - - // Initialize telemetry - var builder = VsixTelemetry.Configure() - .WithServiceName(VsixInfo.DisplayName) - .WithServiceVersion(VsixInfo.Version) - .WithVisualStudioAttributes(this) - .WithEnvironmentAttributes(); + // Initialize telemetry + var builder = VsixTelemetry.Configure() + .WithServiceName(VsixInfo.DisplayName) + .WithServiceVersion(VsixInfo.Version) + .WithVisualStudioAttributes(this) + .WithEnvironmentAttributes(); #if !DEBUG - builder - .WithOtlpHttp("https://api.honeycomb.io") - .WithHeader("x-honeycomb-team", HoneycombConfig.ApiKey); + builder + .WithOtlpHttp("https://api.honeycomb.io") + .WithHeader("x-honeycomb-team", HoneycombConfig.ApiKey); #endif - builder.Initialize(); - - // Initialize services - await InitializeServicesAsync(); - - // Register commands - await RegisterCommandsAsync(); - - // Log successful initialization - VsixTelemetry.LogInformation("Git Ranger initialized successfully"); - await VS.StatusBar.ShowMessageAsync("Git Ranger initialized successfully"); - } + builder.Initialize(); - private async Task InitializeServicesAsync() + // Get services that need explicit initialization + var componentModel = await GetServiceAsync(typeof(SComponentModel)) as IComponentModel; + if (componentModel != null) { - lock (_initLock) + // ThemeService needs async initialization for theme detection + var themeService = componentModel.GetService(); + if (themeService != null) { - // Initialize the theme service first (needed for colors) - if (ThemeService == null) - { - ThemeService = new ThemeService(); - } - - // Initialize the Git service - if (GitService == null) - { - GitService = new GitService(); - } - - // Initialize the Blame service (depends on Git and Theme services) - if (BlameService == null) - { - BlameService = new BlameService(GitService, ThemeService); - } - - _servicesInitialized = true; + await themeService.InitializeAsync(); } - // Initialize theme service async (needs UI thread) - await ThemeService.InitializeAsync(); - } - - private async Task RegisterCommandsAsync() - { - // Register blame commands (toggle inline/gutter, copy SHA) - await BlameCommands.InitializeAsync(this); - // Note: HistoryCommands and GraphCommands are not registered yet (coming soon) + // Keep reference to StatusBarService for disposal + _statusBarService = componentModel.GetService(); } - protected override void Dispose(bool disposing) - { - if (disposing) - { - VsixTelemetry.Shutdown(); - } + // Register commands + await BlameCommands.InitializeAsync(this); - base.Dispose(disposing); - } + // Log successful initialization + VsixTelemetry.LogInformation("Git Ranger initialized successfully"); + await VS.StatusBar.ShowMessageAsync("Git Ranger initialized successfully"); } - /// - /// Package GUIDs. - /// - public static class PackageGuids + protected override void Dispose(bool disposing) { - public const string guidGitRangerPackageString = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890"; - public const string guidGitRangerPackageCmdSetString = "B2C3D4E5-F6A7-8901-BCDE-F12345678902"; - - public static readonly Guid guidGitRangerPackage = new Guid(guidGitRangerPackageString); - public static readonly Guid guidGitRangerPackageCmdSet = new Guid(guidGitRangerPackageCmdSetString); - } + if (disposing) + { + _statusBarService?.Dispose(); + VsixTelemetry.Shutdown(); + } - /// - /// Command IDs. - /// - public static class PackageIds - { - public const int cmdidToggleInlineBlame = 0x0100; - public const int cmdidToggleBlameGutter = 0x0101; - public const int cmdidCopyCommitSha = 0x0106; + base.Dispose(disposing); } } diff --git a/src/CodingWithCalvin.GitRanger/Options/GeneralOptions.cs b/src/CodingWithCalvin.GitRanger/Options/GeneralOptions.cs index 4455acd..c542486 100644 --- a/src/CodingWithCalvin.GitRanger/Options/GeneralOptions.cs +++ b/src/CodingWithCalvin.GitRanger/Options/GeneralOptions.cs @@ -102,6 +102,38 @@ public class GeneralOptions : BaseOptionModel [Description("Show age indicator bars in the gutter.")] [DefaultValue(true)] public bool ShowAgeBars { get; set; } = true; + + // Status Bar + [Category("Status Bar")] + [DisplayName("Enable Status Bar Blame")] + [Description("Show blame information in the status bar for the current line.")] + [DefaultValue(false)] + public bool EnableStatusBarBlame { get; set; } = false; + + [Category("Status Bar")] + [DisplayName("Format")] + [Description("Format template using placeholders: {author}, {date}, {message}, {sha}")] + [DefaultValue("{author}, {date} \u2022 {message}")] + public string StatusBarFormat { get; set; } = "{author}, {date} \u2022 {message}"; + + [Category("Status Bar")] + [DisplayName("Use Relative Dates")] + [Description("Show relative dates (e.g., '2 days ago') instead of absolute dates.")] + [DefaultValue(true)] + public bool StatusBarRelativeDate { get; set; } = true; + + [Category("Status Bar")] + [DisplayName("Maximum Length")] + [Description("Maximum characters to display before truncating. Set to 0 for no limit.")] + [DefaultValue(100)] + public int StatusBarMaxLength { get; set; } = 100; + + // Diagnostics + [Category("Diagnostics")] + [DisplayName("Log Level")] + [Description("Controls output pane verbosity. None=disabled, Error=failures only, Info=key events, Verbose=detailed tracing.")] + [DefaultValue(LogLevel.Error)] + public LogLevel LogLevel { get; set; } = LogLevel.Error; } /// @@ -124,4 +156,30 @@ public enum ColorMode /// Age } + + /// + /// Log level for output pane messages. + /// + public enum LogLevel + { + /// + /// No logging. + /// + None, + + /// + /// Only errors and failures. + /// + Error, + + /// + /// Key lifecycle events and errors. + /// + Info, + + /// + /// Detailed tracing for debugging. + /// + Verbose + } } diff --git a/src/CodingWithCalvin.GitRanger/Options/GeneralOptionsPage.cs b/src/CodingWithCalvin.GitRanger/Options/GeneralOptionsPage.cs index 9c9a23f..955621a 100644 --- a/src/CodingWithCalvin.GitRanger/Options/GeneralOptionsPage.cs +++ b/src/CodingWithCalvin.GitRanger/Options/GeneralOptionsPage.cs @@ -145,6 +145,42 @@ public bool ShowAgeBars set => _options.ShowAgeBars = value; } + // Status Bar Settings + [Category("Status Bar")] + [DisplayName("Enable Status Bar Blame")] + [Description("Show blame information in the status bar for the current line.")] + public bool EnableStatusBarBlame + { + get => _options.EnableStatusBarBlame; + set => _options.EnableStatusBarBlame = value; + } + + [Category("Status Bar")] + [DisplayName("Format")] + [Description("Format template using placeholders: {author}, {date}, {message}, {sha}")] + public string StatusBarFormat + { + get => _options.StatusBarFormat; + set => _options.StatusBarFormat = value; + } + + [Category("Status Bar")] + [DisplayName("Use Relative Dates")] + [Description("Show relative dates (e.g., '2 days ago') instead of absolute dates.")] + public bool StatusBarRelativeDate + { + get => _options.StatusBarRelativeDate; + set => _options.StatusBarRelativeDate = value; + } + + [Category("Status Bar")] + [DisplayName("Maximum Length")] + [Description("Maximum characters to display before truncating. Set to 0 for no limit.")] + public int StatusBarMaxLength + { + get => _options.StatusBarMaxLength; + set => _options.StatusBarMaxLength = Math.Max(0, value); + } /// /// Saves the settings. diff --git a/src/CodingWithCalvin.GitRanger/Services/BlameService.cs b/src/CodingWithCalvin.GitRanger/Services/BlameService.cs index 3c627a3..220ddba 100644 --- a/src/CodingWithCalvin.GitRanger/Services/BlameService.cs +++ b/src/CodingWithCalvin.GitRanger/Services/BlameService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.ComponentModel.Composition; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -12,10 +13,13 @@ namespace CodingWithCalvin.GitRanger.Services /// /// Service for managing blame data with caching and background loading. /// - public class BlameService + [Export(typeof(IBlameService))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class BlameService : IBlameService { - private readonly GitService _gitService; - private readonly ThemeService _themeService; + private readonly IGitService _gitService; + private readonly IThemeService _themeService; + private readonly IOutputPaneService _outputPane; private readonly ConcurrentDictionary _cache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); private static readonly TimeSpan CacheExpiry = TimeSpan.FromMinutes(5); @@ -25,10 +29,14 @@ public class BlameService /// public event EventHandler? BlameLoaded; - public BlameService(GitService gitService, ThemeService themeService) + [ImportingConstructor] + public BlameService(IGitService gitService, IThemeService themeService, IOutputPaneService outputPane) { _gitService = gitService ?? throw new ArgumentNullException(nameof(gitService)); _themeService = themeService ?? throw new ArgumentNullException(nameof(themeService)); + _outputPane = outputPane ?? throw new ArgumentNullException(nameof(outputPane)); + + _outputPane.WriteInfo("BlameService created"); } /// @@ -80,10 +88,13 @@ public Task> GetBlameAsync(string filePath, Cancell /// The file path. public void LoadBlameInBackground(string filePath) { + _outputPane.WriteVerbose("BlameService.LoadBlameInBackground: {0}", filePath); + using var activity = VsixTelemetry.StartCommandActivity("BlameService.LoadBlameInBackground"); if (string.IsNullOrEmpty(filePath)) { + _outputPane.WriteVerbose(" - FilePath is empty"); VsixTelemetry.LogInformation("LoadBlameInBackground - FilePath is empty"); return; } @@ -94,11 +105,13 @@ public void LoadBlameInBackground(string filePath) { VsixTelemetry.LogInformation("Loading blame for file"); var lines = GetBlame(filePath); + _outputPane.WriteVerbose(" - Loaded {0} lines", lines.Count); VsixTelemetry.LogInformation("Loaded {LineCount} blame lines", lines.Count); BlameLoaded?.Invoke(this, new BlameLoadedEventArgs(filePath, lines)); } catch (Exception ex) { + _outputPane.WriteError("BlameService.LoadBlameInBackground failed: {0}", ex.Message); VsixTelemetry.TrackException(ex, new Dictionary { { "operation.name", "LoadBlameInBackground" }, diff --git a/src/CodingWithCalvin.GitRanger/Services/GitService.cs b/src/CodingWithCalvin.GitRanger/Services/GitService.cs index 1352d7c..b02cb00 100644 --- a/src/CodingWithCalvin.GitRanger/Services/GitService.cs +++ b/src/CodingWithCalvin.GitRanger/Services/GitService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel.Composition; using System.Diagnostics; using System.IO; using System.Linq; @@ -11,11 +12,32 @@ namespace CodingWithCalvin.GitRanger.Services /// /// Service for Git repository operations. /// - public class GitService + [Export(typeof(IGitService))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class GitService : IGitService { + private readonly IOutputPaneService _outputPane; private Repository? _currentRepository; private string? _currentRepositoryPath; + [ImportingConstructor] + public GitService(IOutputPaneService outputPane) + { + _outputPane = outputPane ?? throw new ArgumentNullException(nameof(outputPane)); + + // Disable owner validation to avoid "not owned by current user" errors + // This is safe for a VS extension since the user explicitly opened these files + try + { + GlobalSettings.SetOwnerValidation(false); + _outputPane.WriteInfo("GitService created (owner validation disabled)"); + } + catch (Exception ex) + { + _outputPane.WriteError("GitService: could not disable owner validation: {0}", ex.Message); + } + } + /// /// Gets the current repository path, if available. /// @@ -38,27 +60,52 @@ public class GitService /// True if a repository was found, false otherwise. public bool TryOpenRepository(string filePath) { + _outputPane.WriteVerbose("GitService.TryOpenRepository: {0}", filePath); + if (string.IsNullOrEmpty(filePath)) + { + _outputPane.WriteVerbose(" - FilePath is empty"); return false; + } + string? repoPath = null; try { - var repoPath = Repository.Discover(filePath); + repoPath = Repository.Discover(filePath); if (string.IsNullOrEmpty(repoPath)) + { + _outputPane.WriteVerbose(" - No repository found"); return false; + } // Only reopen if it's a different repository if (_currentRepositoryPath != repoPath) { + _outputPane.WriteInfo("Opening repository: {0}", repoPath); _currentRepository?.Dispose(); _currentRepository = new Repository(repoPath); _currentRepositoryPath = repoPath; } + else + { + _outputPane.WriteVerbose(" - Using existing repository"); + } return true; } - catch (Exception) + catch (Exception ex) { + _outputPane.WriteError("GitService.TryOpenRepository failed: {0}", ex.Message); + + // Provide helpful message for common Git safe.directory issue + if (ex.Message.Contains("not owned by current user")) + { + var safePath = repoPath?.TrimEnd('/', '\\') ?? Path.GetDirectoryName(filePath) ?? filePath; + _outputPane.WriteError("*** Git Safe Directory Issue ***"); + _outputPane.WriteError("To fix, run: git config --global --add safe.directory \"{0}\"", safePath); + _outputPane.WriteError("Or to trust all: git config --global --add safe.directory '*'"); + } + return false; } } @@ -70,12 +117,11 @@ public bool TryOpenRepository(string filePath) /// A collection of blame line information. public IReadOnlyList GetBlame(string filePath) { - Debug.WriteLine($"[GitRanger] GitService.GetBlame - FilePath: {filePath}"); - Debug.WriteLine($"[GitRanger] GitService.GetBlame - CurrentRepository: {(_currentRepository == null ? "NULL" : "OK")}"); + _outputPane.WriteVerbose("GitService.GetBlame: {0}", filePath); if (_currentRepository == null || string.IsNullOrEmpty(filePath)) { - Debug.WriteLine("[GitRanger] GitService.GetBlame - Repository null or filepath empty, returning empty"); + _outputPane.WriteVerbose(" - Repository null or filepath empty"); return Array.Empty(); } @@ -85,16 +131,14 @@ public IReadOnlyList GetBlame(string filePath) var repoRoot = _currentRepository.Info.WorkingDirectory; var relativePath = GetRelativePath(repoRoot, filePath); - Debug.WriteLine($"[GitRanger] GitService.GetBlame - RepoRoot: {repoRoot}"); - Debug.WriteLine($"[GitRanger] GitService.GetBlame - RelativePath: {relativePath}"); + _outputPane.WriteVerbose(" - RelativePath: {0}", relativePath); if (string.IsNullOrEmpty(relativePath)) { - Debug.WriteLine("[GitRanger] GitService.GetBlame - RelativePath is empty, returning empty"); + _outputPane.WriteVerbose(" - RelativePath is empty"); return Array.Empty(); } - Debug.WriteLine($"[GitRanger] GitService.GetBlame - Calling Repository.Blame for {relativePath}"); var blameHunks = _currentRepository.Blame(relativePath); var results = new List(); var lineNumber = 1; @@ -117,14 +161,12 @@ public IReadOnlyList GetBlame(string filePath) } } - Debug.WriteLine($"[GitRanger] GitService.GetBlame - Success! Got {results.Count} blame lines"); + _outputPane.WriteVerbose(" - Got {0} blame lines", results.Count); return results; } catch (Exception ex) { - Debug.WriteLine($"[GitRanger] GitService.GetBlame - ERROR: {ex.Message}"); - Debug.WriteLine($"[GitRanger] GitService.GetBlame - StackTrace: {ex.StackTrace}"); - // Return empty on error (file not tracked, etc.) + _outputPane.WriteError("GitService.GetBlame failed: {0}", ex.Message); return Array.Empty(); } } diff --git a/src/CodingWithCalvin.GitRanger/Services/IBlameService.cs b/src/CodingWithCalvin.GitRanger/Services/IBlameService.cs new file mode 100644 index 0000000..42a1990 --- /dev/null +++ b/src/CodingWithCalvin.GitRanger/Services/IBlameService.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using CodingWithCalvin.GitRanger.Core.Models; + +namespace CodingWithCalvin.GitRanger.Services +{ + /// + /// Service interface for managing blame data with caching and background loading. + /// + public interface IBlameService + { + /// + /// Fired when blame data is loaded for a file. + /// + event EventHandler? BlameLoaded; + + /// + /// Gets blame information for a file, using cache if available. + /// + /// The file path. + /// Blame line information, or empty if not available. + IReadOnlyList GetBlame(string filePath); + + /// + /// Gets blame information for a file asynchronously. + /// + /// The file path. + /// Cancellation token. + /// Blame line information. + Task> GetBlameAsync(string filePath, CancellationToken cancellationToken = default); + + /// + /// Loads blame data in the background and fires BlameLoaded when complete. + /// + /// The file path. + void LoadBlameInBackground(string filePath); + + /// + /// Gets blame information for a specific line. + /// + /// The file path. + /// The 1-based line number. + /// Blame info for the line, or null if not available. + BlameLineInfo? GetBlameForLine(string filePath, int lineNumber); + + /// + /// Gets blame information for a range of lines. + /// + /// The file path. + /// The start line (1-based, inclusive). + /// The end line (1-based, inclusive). + /// Blame info for the lines in range. + IReadOnlyList GetBlameForLines(string filePath, int startLine, int endLine); + + /// + /// Invalidates the cache for a specific file. + /// + /// The file path. + void InvalidateCache(string filePath); + + /// + /// Clears all cached blame data. + /// + void ClearCache(); + + /// + /// Ensures blame is loaded for a file, loading if necessary. + /// + /// The file path. + /// True if blame data is available. + bool EnsureBlameLoaded(string filePath); + } +} diff --git a/src/CodingWithCalvin.GitRanger/Services/IGitService.cs b/src/CodingWithCalvin.GitRanger/Services/IGitService.cs new file mode 100644 index 0000000..412f0b6 --- /dev/null +++ b/src/CodingWithCalvin.GitRanger/Services/IGitService.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using CodingWithCalvin.GitRanger.Core.Models; + +namespace CodingWithCalvin.GitRanger.Services +{ + /// + /// Service interface for Git repository operations. + /// + public interface IGitService + { + /// + /// Gets the current repository path, if available. + /// + string? CurrentRepositoryPath { get; } + + /// + /// Gets whether we're currently in a Git repository. + /// + bool IsInRepository { get; } + + /// + /// Gets the current branch name. + /// + string? CurrentBranchName { get; } + + /// + /// Discovers and opens a Git repository for the given file path. + /// + /// A file path within the repository. + /// True if a repository was found, false otherwise. + bool TryOpenRepository(string filePath); + + /// + /// Gets blame information for a file. + /// + /// The file path to blame. + /// A collection of blame line information. + IReadOnlyList GetBlame(string filePath); + + /// + /// Gets blame information for a specific line. + /// + /// The file path. + /// The 1-based line number. + /// Blame info for the line, or null if not available. + BlameLineInfo? GetBlameForLine(string filePath, int lineNumber); + + /// + /// Gets the file history (commits that modified this file). + /// + /// The file path. + /// A collection of commits. + IReadOnlyList GetFileHistory(string filePath); + + /// + /// Gets all commits in the repository for the graph view. + /// + /// Maximum number of commits to retrieve. + /// A collection of commits with branch/merge info. + IReadOnlyList GetCommitGraph(int maxCount = 1000); + + /// + /// Gets all branches in the repository. + /// + IReadOnlyList GetBranches(); + + /// + /// Disposes the current repository. + /// + void Dispose(); + } +} diff --git a/src/CodingWithCalvin.GitRanger/Services/IOutputPaneService.cs b/src/CodingWithCalvin.GitRanger/Services/IOutputPaneService.cs new file mode 100644 index 0000000..906e9c8 --- /dev/null +++ b/src/CodingWithCalvin.GitRanger/Services/IOutputPaneService.cs @@ -0,0 +1,44 @@ +using System.Threading.Tasks; + +namespace CodingWithCalvin.GitRanger.Services; + +/// +/// Service interface for writing to the Git Ranger output pane. +/// +public interface IOutputPaneService +{ + /// + /// Writes an error message to the output pane. + /// + void WriteError(string message); + + /// + /// Writes a formatted error message to the output pane. + /// + void WriteError(string format, params object[] args); + + /// + /// Writes an info message to the output pane. + /// + void WriteInfo(string message); + + /// + /// Writes a formatted info message to the output pane. + /// + void WriteInfo(string format, params object[] args); + + /// + /// Writes a verbose/debug message to the output pane. + /// + void WriteVerbose(string message); + + /// + /// Writes a formatted verbose/debug message to the output pane. + /// + void WriteVerbose(string format, params object[] args); + + /// + /// Activates and shows the output pane. + /// + Task ActivateAsync(); +} diff --git a/src/CodingWithCalvin.GitRanger/Services/IStatusBarService.cs b/src/CodingWithCalvin.GitRanger/Services/IStatusBarService.cs new file mode 100644 index 0000000..4af581c --- /dev/null +++ b/src/CodingWithCalvin.GitRanger/Services/IStatusBarService.cs @@ -0,0 +1,11 @@ +using System; + +namespace CodingWithCalvin.GitRanger.Services +{ + /// + /// Service interface for displaying blame information in the Visual Studio status bar. + /// + public interface IStatusBarService : IDisposable + { + } +} diff --git a/src/CodingWithCalvin.GitRanger/Services/IThemeService.cs b/src/CodingWithCalvin.GitRanger/Services/IThemeService.cs new file mode 100644 index 0000000..523b5f4 --- /dev/null +++ b/src/CodingWithCalvin.GitRanger/Services/IThemeService.cs @@ -0,0 +1,76 @@ +using System; +using System.Threading.Tasks; +using System.Windows.Media; + +namespace CodingWithCalvin.GitRanger.Services +{ + /// + /// Service interface for Visual Studio theme detection and color adaptation. + /// + public interface IThemeService + { + /// + /// Gets whether the current theme is a dark theme. + /// + bool IsDarkTheme { get; } + + /// + /// Gets the current editor background color. + /// + Color EditorBackground { get; } + + /// + /// Gets the current editor foreground color. + /// + Color EditorForeground { get; } + + /// + /// Fired when the VS theme changes. + /// + event EventHandler? ThemeChanged; + + /// + /// Initializes the theme service and subscribes to theme changes. + /// + Task InitializeAsync(); + + /// + /// Gets a consistent color for an author (email-based). + /// + /// The author's email address. + /// A vibrant color for the author. + Color GetAuthorColor(string authorEmail); + + /// + /// Gets a heat map color based on commit age. + /// Recent commits are green, old commits are red. + /// + /// Age of the commit in days. + /// Maximum age to consider (default 365 days). + /// A color from green (new) to red (old). + Color GetAgeHeatMapColor(int ageDays, int maxAgeDays = 365); + + /// + /// Gets a subtle background color for blame annotations. + /// + Color GetBlameBackgroundColor(); + + /// + /// Gets the text color for blame annotations. + /// + Color GetBlameTextColor(); + + /// + /// Gets a color adjusted for the current theme. + /// Makes colors slightly dimmer in dark theme for better visibility. + /// + /// The original color. + /// The adjusted color. + Color AdjustForTheme(Color color); + + /// + /// Clears the author color cache. + /// + void ClearColorCache(); + } +} diff --git a/src/CodingWithCalvin.GitRanger/Services/OutputPaneService.cs b/src/CodingWithCalvin.GitRanger/Services/OutputPaneService.cs new file mode 100644 index 0000000..d3aec32 --- /dev/null +++ b/src/CodingWithCalvin.GitRanger/Services/OutputPaneService.cs @@ -0,0 +1,126 @@ +using System; +using System.ComponentModel.Composition; +using System.Threading.Tasks; +using CodingWithCalvin.GitRanger.Options; +using Community.VisualStudio.Toolkit; +using Microsoft.VisualStudio.Shell; + +namespace CodingWithCalvin.GitRanger.Services; + +/// +/// Service for writing to the Git Ranger output pane. +/// +[Export(typeof(IOutputPaneService))] +[PartCreationPolicy(CreationPolicy.Shared)] +public class OutputPaneService : IOutputPaneService +{ + private const string PaneName = "Git Ranger"; + private OutputWindowPane? _pane; + private readonly object _lock = new(); + + public void WriteError(string message) + { + WriteAtLevel(LogLevel.Error, message); + } + + public void WriteError(string format, params object[] args) + { + WriteError(string.Format(format, args)); + } + + public void WriteInfo(string message) + { + WriteAtLevel(LogLevel.Info, message); + } + + public void WriteInfo(string format, params object[] args) + { + WriteInfo(string.Format(format, args)); + } + + public void WriteVerbose(string message) + { + WriteAtLevel(LogLevel.Verbose, message); + } + + public void WriteVerbose(string format, params object[] args) + { + WriteVerbose(string.Format(format, args)); + } + + private void WriteAtLevel(LogLevel messageLevel, string message) + { + var configuredLevel = GeneralOptions.Instance?.LogLevel ?? LogLevel.Error; + + // None means no logging at all + if (configuredLevel == LogLevel.None) + return; + + // Only write if message level is at or below configured level + if (messageLevel > configuredLevel) + return; + + var timestamp = DateTime.Now.ToString("HH:mm:ss.fff"); + var levelPrefix = messageLevel switch + { + LogLevel.Error => "ERROR", + LogLevel.Info => "INFO", + LogLevel.Verbose => "VERBOSE", + _ => "" + }; + var formattedMessage = $"[{timestamp}] [{levelPrefix}] {message}"; + + _ = WriteLineInternalAsync(formattedMessage); + } + + private async Task WriteLineInternalAsync(string message) + { + try + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + + var pane = await GetOrCreatePaneAsync(); + if (pane != null) + { + await pane.WriteLineAsync(message); + } + } + catch + { + // Silently fail - we don't want logging to break the extension + } + } + + public async Task ActivateAsync() + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + + var pane = await GetOrCreatePaneAsync(); + if (pane != null) + { + await pane.ActivateAsync(); + } + } + + private async Task GetOrCreatePaneAsync() + { + if (_pane != null) + return _pane; + + lock (_lock) + { + if (_pane != null) + return _pane; + } + + try + { + _pane = await VS.Windows.CreateOutputWindowPaneAsync(PaneName); + return _pane; + } + catch + { + return null; + } + } +} diff --git a/src/CodingWithCalvin.GitRanger/Services/StatusBarService.cs b/src/CodingWithCalvin.GitRanger/Services/StatusBarService.cs new file mode 100644 index 0000000..3e8485a --- /dev/null +++ b/src/CodingWithCalvin.GitRanger/Services/StatusBarService.cs @@ -0,0 +1,187 @@ +using System; +using System.ComponentModel.Composition; +using System.Linq; +using System.Threading.Tasks; +using CodingWithCalvin.GitRanger.Core.Models; +using CodingWithCalvin.GitRanger.Options; +using Community.VisualStudio.Toolkit; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Text.Editor; + +namespace CodingWithCalvin.GitRanger.Services +{ + /// + /// Service for displaying blame information in the Visual Studio status bar. + /// Updates in real-time as the cursor moves between lines. + /// + [Export(typeof(IStatusBarService))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class StatusBarService : IStatusBarService + { + private readonly IBlameService _blameService; + private readonly IOutputPaneService _outputPane; + private IWpfTextView? _currentView; + private string? _currentFilePath; + private int _lastLineNumber = -1; + private bool _isDisposed; + + [ImportingConstructor] + public StatusBarService(IBlameService blameService, IOutputPaneService outputPane) + { + _blameService = blameService ?? throw new ArgumentNullException(nameof(blameService)); + _outputPane = outputPane ?? throw new ArgumentNullException(nameof(outputPane)); + + _outputPane.WriteInfo("StatusBarService created"); + + // Subscribe to document/window events + VS.Events.WindowEvents.ActiveFrameChanged += OnActiveFrameChanged; + GeneralOptions.Saved += OnOptionsSaved; + + // Initialize with current document + _ = InitializeAsync(); + } + + private async Task InitializeAsync() + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + await AttachToActiveDocumentAsync(); + } + + private void OnActiveFrameChanged(ActiveFrameChangeEventArgs args) + { + _ = HandleActiveFrameChangedAsync(); + } + + private async Task HandleActiveFrameChangedAsync() + { + await AttachToActiveDocumentAsync(); + } + + private async Task AttachToActiveDocumentAsync() + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + + var docView = await VS.Documents.GetActiveDocumentViewAsync(); + if (docView?.TextView is IWpfTextView wpfView && wpfView != _currentView) + { + DetachFromCurrentView(); + + _currentView = wpfView; + _currentFilePath = docView.FilePath; + _lastLineNumber = -1; + + _currentView.Caret.PositionChanged += OnCaretPositionChanged; + _currentView.Closed += OnViewClosed; + + // Trigger initial update + UpdateStatusBarForCurrentPosition(); + } + } + + private void DetachFromCurrentView() + { + if (_currentView != null) + { + _currentView.Caret.PositionChanged -= OnCaretPositionChanged; + _currentView.Closed -= OnViewClosed; + _currentView = null; + _currentFilePath = null; + } + } + + private void OnViewClosed(object sender, EventArgs e) + { + DetachFromCurrentView(); + _ = ClearStatusBarAsync(); + } + + private void OnCaretPositionChanged(object sender, CaretPositionChangedEventArgs e) + { + UpdateStatusBarForCurrentPosition(); + } + + private void UpdateStatusBarForCurrentPosition() + { + if (_currentView == null) return; + + var lineNumber = _currentView.TextSnapshot + .GetLineNumberFromPosition(_currentView.Caret.Position.BufferPosition.Position) + 1; + + if (lineNumber != _lastLineNumber) + { + _lastLineNumber = lineNumber; + _ = UpdateStatusBarAsync(); + } + } + + private void OnOptionsSaved(GeneralOptions options) + { + // Refresh when options change + _lastLineNumber = -1; + UpdateStatusBarForCurrentPosition(); + } + + private async Task UpdateStatusBarAsync() + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + + var options = await GeneralOptions.GetLiveInstanceAsync(); + if (options == null || !options.EnableStatusBarBlame) + { + await ClearStatusBarAsync(); + return; + } + + if (string.IsNullOrEmpty(_currentFilePath) || _lastLineNumber <= 0) + { + await ClearStatusBarAsync(); + return; + } + + var blameInfo = _blameService.GetBlameForLine(_currentFilePath, _lastLineNumber); + if (blameInfo == null) + { + await ClearStatusBarAsync(); + return; + } + + var message = FormatBlameMessage(blameInfo, options); + await VS.StatusBar.ShowMessageAsync(message); + } + + private static string FormatBlameMessage(BlameLineInfo info, GeneralOptions options) + { + var format = options.StatusBarFormat; + var date = options.StatusBarRelativeDate ? info.RelativeTime : info.AuthorDate.ToString("g"); + var message = info.CommitMessage?.Split('\n').FirstOrDefault() ?? ""; + + var result = format + .Replace("{author}", info.Author ?? "Unknown") + .Replace("{date}", date) + .Replace("{message}", message) + .Replace("{sha}", info.ShortSha ?? ""); + + if (options.StatusBarMaxLength > 0 && result.Length > options.StatusBarMaxLength) + { + result = result.Substring(0, options.StatusBarMaxLength - 3) + "..."; + } + + return $"Git Ranger: {result}"; + } + + private static async Task ClearStatusBarAsync() + { + await VS.StatusBar.ClearAsync(); + } + + public void Dispose() + { + if (_isDisposed) return; + _isDisposed = true; + + DetachFromCurrentView(); + VS.Events.WindowEvents.ActiveFrameChanged -= OnActiveFrameChanged; + GeneralOptions.Saved -= OnOptionsSaved; + } + } +} diff --git a/src/CodingWithCalvin.GitRanger/Services/ThemeService.cs b/src/CodingWithCalvin.GitRanger/Services/ThemeService.cs index 5eba883..4e45f15 100644 --- a/src/CodingWithCalvin.GitRanger/Services/ThemeService.cs +++ b/src/CodingWithCalvin.GitRanger/Services/ThemeService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel.Composition; using System.Threading.Tasks; using System.Windows.Media; using Community.VisualStudio.Toolkit; @@ -11,7 +12,9 @@ namespace CodingWithCalvin.GitRanger.Services /// /// Service for Visual Studio theme detection and color adaptation. /// - public class ThemeService + [Export(typeof(IThemeService))] + [PartCreationPolicy(CreationPolicy.Shared)] + public class ThemeService : IThemeService { private static readonly Color[] VibrantAuthorColors = new[] { diff --git a/src/CodingWithCalvin.GitRanger/source.extension.vsixmanifest b/src/CodingWithCalvin.GitRanger/source.extension.vsixmanifest index af3d54b..0d778d6 100644 --- a/src/CodingWithCalvin.GitRanger/source.extension.vsixmanifest +++ b/src/CodingWithCalvin.GitRanger/source.extension.vsixmanifest @@ -4,10 +4,10 @@ Git Ranger A visually stunning Git management extension for Visual Studio 2022/2026, with theme-adaptive vibrant colors. - https://github.com/calvinallen/GitRanger + https://github.com/CodingWithCalvin/VS-GitRanger/ Resources\LICENSE - https://github.com/calvinallen/GitRanger#readme - https://github.com/calvinallen/GitRanger/releases + https://github.com/CodingWithCalvin/VS-GitRanger/#readme + https://github.com/CodingWithCalvin/VS-GitRanger/releases Resources\icon.png Resources\icon.png git, blame, history, graph, version control