Skip to content

Commit 6cc30cd

Browse files
Refactor file discovery, cache, and output batching 🚀
- Replace ConcurrentDictionary with HashSet for file deduplication in commands - Add FileDiscovery utility for recursive file enumeration with directory exclusion - Standardize all file/folder searches to use FileDiscovery.EnumerateFiles - Enhance cache to store file size and timestamp; validate both for optimization - Support legacy cache file format for backward compatibility - Batch output window result rows and write headers only when needed - Batch status bar progress updates for better performance - Extend Constants with excluded directory names and progress batch size - Update README for improved formatting and new API/options documentation - Add and update unit tests for file discovery and cache validation logic
1 parent 488998d commit 6cc30cd

12 files changed

Lines changed: 391 additions & 212 deletions

‎README.md‎

Lines changed: 48 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
[marketplace]: https://marketplace.visualstudio.com/items?itemName=MadsKristensen.ImageOptimizer64bit
2-
[vsixgallery]: http://vsixgallery.com/extension/fc4e241f-57de-4032-9c89-527984c0a0ae/
3-
[repo]:https://github.com/madskristensen/ImageOptimizer
1+
[marketplace]: <https://marketplace.visualstudio.com/items?itemName=MadsKristensen.ImageOptimizer64bit>
2+
[vsixgallery]: <http://vsixgallery.com/extension/fc4e241f-57de-4032-9c89-527984c0a0ae/>
3+
[repo]:<https://github.com/madskristensen/ImageOptimizer>
44

55
# Image Optimizer for Visual Studio
66

@@ -19,7 +19,7 @@ and lossless optimization.
1919
## Features
2020

2121
Adds a right-click menu to any folder and image in Solution Explorer
22-
that lets you optimize all images in that folder.
22+
that lets you optimize all images in that folder.
2323

2424
- **Optimize** PNG, JPG, WebP, AVIF, SVG, and GIF (including animated GIFs) images
2525
- **Optimize embedded images** inside `.resx` resource files
@@ -33,7 +33,7 @@ that lets you optimize all images in that folder.
3333

3434
## Optimize Images
3535

36-
Simply right-click any file or folder containing images and click
36+
Simply right-click any file or folder containing images and click
3737
one of the image optimization buttons.
3838

3939
![Context menu](art/context-menu.png)
@@ -117,79 +117,81 @@ Configure the extension via **Tools > Options > Image Optimizer**.
117117

118118
### Compression
119119

120-
| Option | Description | Default |
121-
|--------|-------------|---------|
122-
| **Lossy Quality** | Quality level for lossy compression (60-100). Higher values preserve more quality but reduce savings. | 85 |
120+
| Option | Description | Default |
121+
| ----------------- | ----------------------------------------------------------------------------------------------------- | ------- |
122+
| **Lossy Quality** | Quality level for lossy compression (60-100). Higher values preserve more quality but reduce savings. | 85 |
123123

124124
### Performance
125125

126-
| Option | Description | Default |
127-
|--------|-------------|---------|
128-
| **Process Timeout** | Maximum seconds to wait for compression before timing out (10-300). | 60 |
129-
| **Max Parallel Threads** | Number of parallel threads for processing. 0 = automatic (uses processor count). | 0 |
126+
| Option | Description | Default |
127+
| ------------------------ | -------------------------------------------------------------------------------- | ------- |
128+
| **Process Timeout** | Maximum seconds to wait for compression before timing out (10-300). | 60 |
129+
| **Max Parallel Threads** | Number of parallel threads for processing. 0 = automatic (uses processor count). | 0 |
130130

131131
### Cache
132132

133-
| Option | Description | Default |
134-
|--------|-------------|---------|
135-
| **Enable Caching** | Cache optimization results to avoid reprocessing unchanged files. | On |
136-
| **Cache Validation** | Validate cached files by checking file size. Disable for faster operation. | On |
133+
| Option | Description | Default |
134+
| -------------------- | -------------------------------------------------------------------------- | ------- |
135+
| **Enable Caching** | Cache optimization results to avoid reprocessing unchanged files. | On |
136+
| **Cache Validation** | Validate cached files by checking file size. Disable for faster operation. | On |
137137

138138
### Safety
139139

140-
| Option | Description | Default |
141-
|--------|-------------|---------|
142-
| **Create Backup** | Create backup copies before optimization. Backups are stored in the `.vs` folder. | Off |
140+
| Option | Description | Default |
141+
| ----------------- | --------------------------------------------------------------------------------- | ------- |
142+
| **Create Backup** | Create backup copies before optimization. Backups are stored in the `.vs` folder. | Off |
143143

144144
### User Interface
145145

146-
| Option | Description | Default |
147-
|--------|-------------|---------|
148-
| **Show Progress in Status Bar** | Display real-time optimization progress in the status bar. | On |
149-
| **Show Detailed Results** | Show per-file compression results in the Output Window. | On |
146+
| Option | Description | Default |
147+
| ------------------------------- | ---------------------------------------------------------- | ------- |
148+
| **Show Progress in Status Bar** | Display real-time optimization progress in the status bar. | On |
149+
| **Show Detailed Results** | Show per-file compression results in the Output Window. | On |
150150

151151
### Error Handling
152152

153-
| Option | Description | Default |
154-
|--------|-------------|---------|
155-
| **Continue on Error** | Continue processing other images if one fails. | On |
156-
| **Log Errors to Output** | Log detailed error information to the Output Window. | On |
153+
| Option | Description | Default |
154+
| ------------------------ | ---------------------------------------------------- | ------- |
155+
| **Continue on Error** | Continue processing other images if one fails. | On |
156+
| **Log Errors to Output** | Log detailed error information to the Output Window. | On |
157157

158158
## API for Extenders
159+
159160
Any extension can call the commands provided in the Image Optimizer extension to optimize any image. The [Markdown Editor v2](https://marketplace.visualstudio.com/items?itemName=MadsKristensen.MarkdownEditor2) extension uses this API.
160161

161162
```c#
162163
public void OptimizeImage(string filePath)
163164
{
164-
try
165-
{
166-
var DTE = (DTE2)Package.GetGlobalService(typeof(DTE));
167-
Command command = DTE.Commands.Item("ImageOptimizer.OptimizeLossless");
168-
169-
if (command != null && command.IsAvailable)
170-
{
171-
DTE.Commands.Raise(command.Guid, command.ID, filePath, null);
172-
}
173-
}
174-
catch (Exception ex)
175-
{
176-
// Image Optimizer not installed
177-
}
165+
try
166+
{
167+
var DTE = (DTE2)Package.GetGlobalService(typeof(DTE)); var DTE = (DTE2)Package.GetGlobalService(typeof(DTE));
168+
Command command = DTE.Commands.Item("ImageOptimizer.OptimizeLossless"); Command command = DTE.Commands.Item("ImageOptimizer.OptimizeLossless");
169+
170+
if (command != null && command.IsAvailable) if (command != null && command.IsAvailable)
171+
{ {
172+
DTE.Commands.Raise(command.Guid, command.ID, filePath, null); DTE.Commands.Raise(command.Guid, command.ID, filePath, null); DTE.Commands.Raise(command.Guid, command.ID, filePath, null);
173+
} }
174+
}
175+
catch (Exception ex)
176+
{
177+
// Image Optimizer not installed // Image Optimizer not installed
178+
}
178179
}
179180
```
180181

181182
The commands are:
182183

183-
* ImageOptimizer.OptimizeLossless - *Optimize for best quality*
184-
* ImageOptimizer.OptimizeLossy - *Optimize for best compression*
185-
* ImageOptimizer.ConvertToWebP - *Convert PNG/JPG to WebP*
186-
* ImageOptimizer.ConvertToAvif - *Convert PNG/JPG to AVIF*
184+
- ImageOptimizer.OptimizeLossless - *Optimize for best quality*
185+
- ImageOptimizer.OptimizeLossy - *Optimize for best compression*
186+
- ImageOptimizer.ConvertToWebP - *Convert PNG/JPG to WebP*
187+
- ImageOptimizer.ConvertToAvif - *Convert PNG/JPG to AVIF*
187188

188189
## How can I help?
190+
189191
If you enjoy using the extension, please give it a ★★★★★ rating on the [Visual Studio Marketplace][marketplace].
190192

191193
Should you encounter bugs or if you have feature requests, head on over to the [GitHub repo][repo] to open an issue if one doesn't already exist.
192194

193195
Pull requests are also very welcome, since I can't always get around to fixing all bugs myself. This is a personal passion project, so my time is limited.
194196

195-
Another way to help out is to [sponsor me on GitHub](https://github.com/sponsors/madskristensen).
197+
Another way to help out is to [sponsor me on GitHub](https://github.com/sponsors/madskristensen).

‎src/Commands/OptimizeLosslessCommand.cs‎

Lines changed: 14 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
using System.Collections.Concurrent;
21
using System.Collections.Generic;
32
using System.IO;
43
using System.Linq;
@@ -47,8 +46,7 @@ protected override async Task ExecuteAsync(OleMenuCmdEventArgs e)
4746
/// </summary>
4847
public static async Task<IEnumerable<string>> GetImageFilesAsync(OleMenuCmdEventArgs e)
4948
{
50-
// Use thread-safe ConcurrentDictionary as a set (value is ignored)
51-
var files = new ConcurrentDictionary<string, byte>(StringComparer.OrdinalIgnoreCase);
49+
var files = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
5250

5351
// Check command parameters first
5452
if (e.InValue is string arg)
@@ -57,7 +55,7 @@ public static async Task<IEnumerable<string>> GetImageFilesAsync(OleMenuCmdEvent
5755

5856
if (Compressor.IsFileSupported(filePath) && File.Exists(filePath))
5957
{
60-
files.TryAdd(filePath, 0);
58+
_ = files.Add(filePath);
6159
}
6260
}
6361
// Then check selected nodes in Solution Explorer
@@ -72,7 +70,7 @@ public static async Task<IEnumerable<string>> GetImageFilesAsync(OleMenuCmdEvent
7270
case SolutionItemType.PhysicalFile:
7371
if (Compressor.IsFileSupported(item.FullPath))
7472
{
75-
files.TryAdd(item.FullPath, 0);
73+
_ = files.Add(item.FullPath);
7674
}
7775
break;
7876

@@ -92,30 +90,14 @@ public static async Task<IEnumerable<string>> GetImageFilesAsync(OleMenuCmdEvent
9290
}
9391
}
9492

95-
return files.Keys;
93+
return files;
9694
}
9795

98-
private static void AddSupportedFilesFromDirectory(string directoryPath, ConcurrentDictionary<string, byte> files)
96+
private static void AddSupportedFilesFromDirectory(string directoryPath, HashSet<string> files)
9997
{
100-
try
98+
foreach (var file in FileDiscovery.EnumerateFiles(directoryPath, Compressor.IsFileSupported))
10199
{
102-
// Sequential enumeration is more efficient for I/O-bound operations
103-
// as PLINQ adds overhead without benefit for file system access
104-
IEnumerable<string> supportedFiles = Directory.EnumerateFiles(directoryPath, Constants.AllFilesPattern, SearchOption.AllDirectories)
105-
.Where(Compressor.IsFileSupported);
106-
107-
foreach (var file in supportedFiles)
108-
{
109-
files.TryAdd(file, 0);
110-
}
111-
}
112-
catch (UnauthorizedAccessException ex)
113-
{
114-
ex.LogAsync().FireAndForget();
115-
}
116-
catch (IOException ex)
117-
{
118-
ex.LogAsync().FireAndForget();
100+
_ = files.Add(file);
119101
}
120102
}
121103

@@ -124,14 +106,14 @@ private static void AddSupportedFilesFromDirectory(string directoryPath, Concurr
124106
/// </summary>
125107
public static async Task<IEnumerable<string>> GetResxFilesAsync(OleMenuCmdEventArgs e)
126108
{
127-
var files = new ConcurrentDictionary<string, byte>(StringComparer.OrdinalIgnoreCase);
109+
var files = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
128110

129111
if (e.InValue is string arg)
130112
{
131113
var filePath = arg.Trim('"', '\'');
132114
if (FileUtilities.IsResxFile(filePath) && File.Exists(filePath))
133115
{
134-
files.TryAdd(filePath, 0);
116+
_ = files.Add(filePath);
135117
}
136118
}
137119
else
@@ -145,7 +127,7 @@ public static async Task<IEnumerable<string>> GetResxFilesAsync(OleMenuCmdEventA
145127
case SolutionItemType.PhysicalFile:
146128
if (FileUtilities.IsResxFile(item.FullPath))
147129
{
148-
files.TryAdd(item.FullPath, 0);
130+
_ = files.Add(item.FullPath);
149131
}
150132
break;
151133

@@ -165,27 +147,14 @@ public static async Task<IEnumerable<string>> GetResxFilesAsync(OleMenuCmdEventA
165147
}
166148
}
167149

168-
return files.Keys;
150+
return files;
169151
}
170152

171-
private static void AddResxFilesFromDirectory(string directoryPath, ConcurrentDictionary<string, byte> files)
153+
private static void AddResxFilesFromDirectory(string directoryPath, HashSet<string> files)
172154
{
173-
try
174-
{
175-
IEnumerable<string> resxFiles = Directory.EnumerateFiles(directoryPath, "*" + Constants.ResxExtension, SearchOption.AllDirectories);
176-
177-
foreach (var file in resxFiles)
178-
{
179-
files.TryAdd(file, 0);
180-
}
181-
}
182-
catch (UnauthorizedAccessException ex)
183-
{
184-
ex.LogAsync().FireAndForget();
185-
}
186-
catch (IOException ex)
155+
foreach (var file in FileDiscovery.EnumerateFiles(directoryPath, FileUtilities.IsResxFile))
187156
{
188-
ex.LogAsync().FireAndForget();
157+
_ = files.Add(file);
189158
}
190159
}
191160
}

‎src/Commands/WorkspaceOptimizeCommand.cs‎

Lines changed: 11 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
using System.Collections.Concurrent;
21
using System.Collections.Generic;
32
using System.ComponentModel.Composition;
43
using System.IO;
54
using System.Linq;
5+
using MadsKristensen.ImageOptimizer.Common;
66
using Microsoft.VisualStudio;
77
using Microsoft.VisualStudio.OLE.Interop;
88
using Microsoft.VisualStudio.Workspace.VSIntegration.UI;
@@ -96,94 +96,50 @@ public int Exec(List<WorkspaceVisualNodeBase> selection, Guid pguidCmdGroup, uin
9696

9797
private static IEnumerable<string> GetImageFiles(List<WorkspaceVisualNodeBase> selectedNodes)
9898
{
99-
// Use thread-safe ConcurrentDictionary as a set for deduplication
100-
var processedFiles = new ConcurrentDictionary<string, byte>(StringComparer.OrdinalIgnoreCase);
101-
var resultFiles = new List<string>();
99+
var processedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
102100

103101
foreach (WorkspaceVisualNodeBase selection in selectedNodes)
104102
{
105103
switch (selection)
106104
{
107105
case IFolderNode folder:
108-
try
106+
foreach (var image in FileDiscovery.EnumerateFiles(folder.FullPath, Compressor.IsFileSupported))
109107
{
110-
// Sequential enumeration is more efficient for I/O-bound operations
111-
IEnumerable<string> images = Directory.EnumerateFiles(folder.FullPath, Constants.AllFilesPattern, SearchOption.AllDirectories)
112-
.Where(Compressor.IsFileSupported);
113-
114-
foreach (var image in images)
115-
{
116-
if (processedFiles.TryAdd(image, 0))
117-
{
118-
resultFiles.Add(image);
119-
}
120-
}
121-
}
122-
catch (UnauthorizedAccessException ex)
123-
{
124-
ex.LogAsync().FireAndForget();
125-
}
126-
catch (IOException ex)
127-
{
128-
ex.LogAsync().FireAndForget();
108+
_ = processedFiles.Add(image);
129109
}
130110
break;
131111

132112
case IFileNode file when Compressor.IsFileSupported(file.FullPath):
133-
if (processedFiles.TryAdd(file.FullPath, 0))
134-
{
135-
resultFiles.Add(file.FullPath);
136-
}
113+
_ = processedFiles.Add(file.FullPath);
137114
break;
138115
}
139116
}
140117

141-
return resultFiles;
118+
return processedFiles;
142119
}
143120

144121
private static IEnumerable<string> GetConvertibleFiles(List<WorkspaceVisualNodeBase> selectedNodes, Func<string, bool> isConvertible)
145122
{
146-
var processedFiles = new ConcurrentDictionary<string, byte>(StringComparer.OrdinalIgnoreCase);
147-
var resultFiles = new List<string>();
123+
var processedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
148124

149125
foreach (WorkspaceVisualNodeBase selection in selectedNodes)
150126
{
151127
switch (selection)
152128
{
153129
case IFolderNode folder:
154-
try
130+
foreach (var image in FileDiscovery.EnumerateFiles(folder.FullPath, isConvertible))
155131
{
156-
IEnumerable<string> images = Directory.EnumerateFiles(folder.FullPath, Constants.AllFilesPattern, SearchOption.AllDirectories)
157-
.Where(isConvertible);
158-
159-
foreach (var image in images)
160-
{
161-
if (processedFiles.TryAdd(image, 0))
162-
{
163-
resultFiles.Add(image);
164-
}
165-
}
166-
}
167-
catch (UnauthorizedAccessException ex)
168-
{
169-
ex.LogAsync().FireAndForget();
170-
}
171-
catch (IOException ex)
172-
{
173-
ex.LogAsync().FireAndForget();
132+
_ = processedFiles.Add(image);
174133
}
175134
break;
176135

177136
case IFileNode file when isConvertible(file.FullPath):
178-
if (processedFiles.TryAdd(file.FullPath, 0))
179-
{
180-
resultFiles.Add(file.FullPath);
181-
}
137+
_ = processedFiles.Add(file.FullPath);
182138
break;
183139
}
184140
}
185141

186-
return resultFiles;
142+
return processedFiles;
187143
}
188144

189145
/// <inheritdoc/>

0 commit comments

Comments
 (0)