Skip to content

Commit f345b04

Browse files
committed
refactor: replace tool window with WPF shell injection
Switch from ToolWindowPane to direct WPF visual tree injection to create a persistent launcher bar without window chrome. The bar injects between the toolbar and status bar on the left side of the VS shell. - Add ShellInjectionService to walk VS visual tree and inject UI - Add LaunchyBarControl to UI folder with VS theme integration - Remove ToolWindow and Commands folders - Update LaunchyBarPackage to use injection service - Add exception handling to async void methods
1 parent 3ea72e7 commit f345b04

10 files changed

Lines changed: 410 additions & 105 deletions

File tree

src/CodingWithCalvin.LaunchyBar/Commands/ShowLaunchyBarCommand.cs

Lines changed: 0 additions & 15 deletions
This file was deleted.

src/CodingWithCalvin.LaunchyBar/LaunchyBarPackage.cs

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,24 @@
33
using System.Threading;
44
using CodingWithCalvin.LaunchyBar.Options;
55
using CodingWithCalvin.LaunchyBar.Services;
6-
using CodingWithCalvin.LaunchyBar.ToolWindow;
76
using CodingWithCalvin.Otel4Vsix;
8-
using Community.VisualStudio.Toolkit;
97
using Microsoft.VisualStudio;
108
using Microsoft.VisualStudio.Shell;
11-
using Microsoft.VisualStudio.Shell.Interop;
129
using Task = System.Threading.Tasks.Task;
1310

1411
namespace CodingWithCalvin.LaunchyBar;
1512

1613
[PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)]
1714
[Guid(VSCommandTableVsct.guidLaunchyBarPackageString)]
18-
[ProvideMenuResource("Menus.ctmenu", 1)]
19-
[ProvideToolWindow(typeof(LaunchyBarWindow))]
2015
[ProvideAutoLoad(VSConstants.UICONTEXT.ShellInitialized_string, PackageAutoLoadFlags.BackgroundLoad)]
2116
[ProvideOptionPage(typeof(OptionsProvider.GeneralOptionsPage), "LaunchyBar", "General", 0, 0, true)]
22-
public sealed class LaunchyBarPackage : ToolkitPackage
17+
public sealed class LaunchyBarPackage : AsyncPackage
2318
{
2419
public static LaunchyBarPackage? Instance { get; private set; }
2520

26-
internal IConfigurationService? ConfigurationService { get; private set; }
27-
internal ILaunchService? LaunchService { get; private set; }
21+
private IConfigurationService? _configurationService;
22+
private ILaunchService? _launchService;
23+
private IShellInjectionService? _shellInjectionService;
2824

2925
protected override async Task InitializeAsync(CancellationToken cancellationToken, IProgress<ServiceProgressData> progress)
3026
{
@@ -48,16 +44,24 @@ protected override async Task InitializeAsync(CancellationToken cancellationToke
4844

4945
builder.Initialize();
5046

51-
ConfigurationService = new ConfigurationService();
52-
LaunchService = new LaunchService(this);
47+
// Initialize services
48+
_configurationService = new ConfigurationService();
49+
_launchService = new LaunchService(this);
5350

54-
await this.RegisterCommandsAsync();
51+
// Delay injection slightly to ensure VS UI is fully loaded
52+
await Task.Delay(1000, cancellationToken);
53+
await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
54+
55+
// Inject the bar into VS shell
56+
_shellInjectionService = new ShellInjectionService(_configurationService, _launchService);
57+
_shellInjectionService.Inject();
5558
}
5659

5760
protected override void Dispose(bool disposing)
5861
{
5962
if (disposing)
6063
{
64+
_shellInjectionService?.Dispose();
6165
Instance = null;
6266
VsixTelemetry.Shutdown();
6367
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using System;
2+
3+
namespace CodingWithCalvin.LaunchyBar.Services;
4+
5+
/// <summary>
6+
/// Service for injecting custom UI into the Visual Studio shell.
7+
/// </summary>
8+
public interface IShellInjectionService : IDisposable
9+
{
10+
/// <summary>
11+
/// Injects the LaunchyBar into the VS shell.
12+
/// </summary>
13+
/// <returns>True if injection succeeded, false otherwise.</returns>
14+
bool Inject();
15+
16+
/// <summary>
17+
/// Removes the LaunchyBar from the VS shell.
18+
/// </summary>
19+
void Remove();
20+
21+
/// <summary>
22+
/// Gets whether the bar is currently injected.
23+
/// </summary>
24+
bool IsInjected { get; }
25+
}
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
using System;
2+
using System.Linq;
3+
using System.Windows;
4+
using System.Windows.Controls;
5+
using System.Windows.Media;
6+
using CodingWithCalvin.LaunchyBar.UI;
7+
using Microsoft.VisualStudio.Shell;
8+
9+
namespace CodingWithCalvin.LaunchyBar.Services;
10+
11+
/// <summary>
12+
/// Service for injecting the LaunchyBar into the Visual Studio shell's visual tree.
13+
/// </summary>
14+
public sealed class ShellInjectionService : IShellInjectionService
15+
{
16+
private readonly IConfigurationService _configurationService;
17+
private readonly ILaunchService _launchService;
18+
19+
private LaunchyBarControl? _barControl;
20+
private Grid? _injectedGrid;
21+
private FrameworkElement? _originalContent;
22+
private ContentPresenter? _targetPresenter;
23+
24+
private const double BarWidth = 48;
25+
26+
public ShellInjectionService(IConfigurationService configurationService, ILaunchService launchService)
27+
{
28+
_configurationService = configurationService;
29+
_launchService = launchService;
30+
}
31+
32+
public bool IsInjected => _barControl != null;
33+
34+
public bool Inject()
35+
{
36+
ThreadHelper.ThrowIfNotOnUIThread();
37+
38+
if (IsInjected)
39+
return true;
40+
41+
try
42+
{
43+
var mainWindow = Application.Current.MainWindow;
44+
if (mainWindow == null)
45+
return false;
46+
47+
// Find the main content area - we're looking for the area between toolbar and status bar
48+
// This requires walking VS's visual tree to find the right injection point
49+
var injectionTarget = FindInjectionTarget(mainWindow);
50+
if (injectionTarget == null)
51+
return false;
52+
53+
_targetPresenter = injectionTarget;
54+
_originalContent = injectionTarget.Content as FrameworkElement;
55+
56+
if (_originalContent == null)
57+
return false;
58+
59+
// Create our bar control
60+
_barControl = new LaunchyBarControl(_configurationService, _launchService);
61+
_barControl.Width = BarWidth;
62+
_barControl.HorizontalAlignment = HorizontalAlignment.Left;
63+
_barControl.VerticalAlignment = VerticalAlignment.Stretch;
64+
65+
// Create a new grid to hold both the bar and the original content
66+
_injectedGrid = new Grid();
67+
_injectedGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(BarWidth) });
68+
_injectedGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
69+
70+
// Remove original content from its parent
71+
injectionTarget.Content = null;
72+
73+
// Add bar to column 0
74+
Grid.SetColumn(_barControl, 0);
75+
_injectedGrid.Children.Add(_barControl);
76+
77+
// Add original content to column 1
78+
Grid.SetColumn(_originalContent, 1);
79+
_injectedGrid.Children.Add(_originalContent);
80+
81+
// Set our grid as the new content
82+
injectionTarget.Content = _injectedGrid;
83+
84+
return true;
85+
}
86+
catch (Exception)
87+
{
88+
Remove();
89+
return false;
90+
}
91+
}
92+
93+
public void Remove()
94+
{
95+
ThreadHelper.ThrowIfNotOnUIThread();
96+
97+
if (_targetPresenter != null && _originalContent != null && _injectedGrid != null)
98+
{
99+
try
100+
{
101+
_injectedGrid.Children.Clear();
102+
_targetPresenter.Content = _originalContent;
103+
}
104+
catch
105+
{
106+
// Best effort cleanup
107+
}
108+
}
109+
110+
_barControl = null;
111+
_injectedGrid = null;
112+
_originalContent = null;
113+
_targetPresenter = null;
114+
}
115+
116+
/// <summary>
117+
/// Finds the ContentPresenter that contains the main VS content area.
118+
/// This is the area between the toolbar and status bar.
119+
/// </summary>
120+
private ContentPresenter? FindInjectionTarget(Window mainWindow)
121+
{
122+
// Strategy: Walk the visual tree looking for a ContentPresenter
123+
// that contains the main dock/editor area.
124+
// This is fragile and may need adjustment for different VS versions.
125+
126+
// Look for a ContentPresenter with a specific name or structure
127+
// VS typically has a structure like:
128+
// MainWindow > ... > DockPanel > [Toolbar, ContentPresenter (main area), StatusBar]
129+
130+
return FindContentPresenterRecursive(mainWindow, 0);
131+
}
132+
133+
private ContentPresenter? FindContentPresenterRecursive(DependencyObject parent, int depth)
134+
{
135+
if (depth > 20) // Prevent infinite recursion
136+
return null;
137+
138+
var childCount = VisualTreeHelper.GetChildrenCount(parent);
139+
140+
for (int i = 0; i < childCount; i++)
141+
{
142+
var child = VisualTreeHelper.GetChild(parent, i);
143+
144+
// Look for ContentPresenter that might be our target
145+
if (child is ContentPresenter cp)
146+
{
147+
// Check if this ContentPresenter's content looks like the main content area
148+
if (IsMainContentArea(cp))
149+
{
150+
return cp;
151+
}
152+
}
153+
154+
// Also check for Grid with DockPanel children - common VS structure
155+
if (child is Grid grid)
156+
{
157+
// Look for the main content grid that has the editor/tool area
158+
var result = FindContentPresenterRecursive(grid, depth + 1);
159+
if (result != null)
160+
return result;
161+
}
162+
163+
if (child is DockPanel dockPanel)
164+
{
165+
var result = FindContentPresenterRecursive(dockPanel, depth + 1);
166+
if (result != null)
167+
return result;
168+
}
169+
170+
if (child is Border border)
171+
{
172+
var result = FindContentPresenterRecursive(border, depth + 1);
173+
if (result != null)
174+
return result;
175+
}
176+
177+
if (child is Decorator decorator)
178+
{
179+
var result = FindContentPresenterRecursive(decorator, depth + 1);
180+
if (result != null)
181+
return result;
182+
}
183+
}
184+
185+
return null;
186+
}
187+
188+
private bool IsMainContentArea(ContentPresenter cp)
189+
{
190+
// Heuristics to identify the main content area:
191+
// 1. Should be reasonably large
192+
// 2. Should contain dock-related content
193+
194+
if (cp.ActualWidth < 400 || cp.ActualHeight < 300)
195+
return false;
196+
197+
// Check if the content's type name contains dock-related keywords
198+
var content = cp.Content;
199+
if (content == null)
200+
return false;
201+
202+
var typeName = content.GetType().FullName ?? "";
203+
204+
// VS's main content area typically has these in the type hierarchy
205+
if (typeName.Contains("Dock") ||
206+
typeName.Contains("ViewManager") ||
207+
typeName.Contains("MainWindow") ||
208+
typeName.Contains("Workspace"))
209+
{
210+
return true;
211+
}
212+
213+
return false;
214+
}
215+
216+
public void Dispose()
217+
{
218+
try
219+
{
220+
ThreadHelper.JoinableTaskFactory.Run(async () =>
221+
{
222+
await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();
223+
Remove();
224+
});
225+
}
226+
catch
227+
{
228+
// Best effort
229+
}
230+
}
231+
}

src/CodingWithCalvin.LaunchyBar/ToolWindow/LaunchyBarControl.xaml

Lines changed: 0 additions & 18 deletions
This file was deleted.

src/CodingWithCalvin.LaunchyBar/ToolWindow/LaunchyBarControl.xaml.cs

Lines changed: 0 additions & 14 deletions
This file was deleted.

0 commit comments

Comments
 (0)