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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion Flow.Launcher.Core/Plugin/PluginConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Flow.Launcher.Plugin;
using System.Text.Json;
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Plugin.SharedCommands;

namespace Flow.Launcher.Core.Plugin
{
Expand All @@ -30,7 +31,17 @@ public static List<PluginMetadata> Parse(string[] pluginDirectories)
{
try
{
Directory.Delete(directory, true);
var fullyDeleted = FilesFolders.TryDeleteDirectoryRobust(directory, maxRetries: 3, retryDelayMs: 100);
if (!fullyDeleted)
{
// Directory was not fully deleted, recreate the marker file so deletion will be retried on next startup
var markerFilePath = Path.Combine(directory, DataLocation.PluginDeleteFile);
if (!File.Exists(markerFilePath))
{
File.WriteAllText(markerFilePath, string.Empty);
}
PublicApi.Instance.LogWarn(ClassName, $"Directory <{directory}> was not fully deleted. Some files or folders may still remain. Deletion will be retried on next startup.");
}
}
catch (Exception e)
{
Expand Down
113 changes: 113 additions & 0 deletions Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,119 @@ public static void RemoveFolderIfExists(this string path, Func<string, MessageBo
}
}

/// <summary>
/// Attempts to delete a directory robustly with retry logic for locked files.
/// This method tries to delete files individually with retries, then removes empty directories.
/// Returns true if the directory was completely deleted, false if some files/folders remain.
/// </summary>
/// <param name="path">The directory path to delete</param>
/// <param name="maxRetries">Maximum number of retry attempts for locked files (default: 3)</param>
/// <param name="retryDelayMs">Delay in milliseconds between retries (default: 100ms)</param>
/// <returns>True if directory was fully deleted, false if some items remain</returns>
public static bool TryDeleteDirectoryRobust(string path, int maxRetries = 3, int retryDelayMs = 100)
{
if (!Directory.Exists(path))
return true;

bool fullyDeleted = true;

try
{
// First, try to delete all files in the directory tree
var files = Directory.GetFiles(path, "*", SearchOption.AllDirectories);
foreach (var file in files)
{
bool fileDeleted = false;
for (int attempt = 0; attempt <= maxRetries; attempt++)
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Off-by-one error in retry logic. The loop condition 'attempt <= maxRetries' with 'attempt' starting at 0 means that when maxRetries is 3, the code will make 4 attempts (0, 1, 2, 3) instead of the intended 3. Change the loop condition to 'attempt < maxRetries' to ensure the number of attempts matches the parameter value, or adjust the documentation to clarify that maxRetries represents the number of retries after the initial attempt.

Copilot uses AI. Check for mistakes.
{
try
{
// Remove read-only attribute if present
var fileInfo = new FileInfo(file);
if (fileInfo.Exists && fileInfo.IsReadOnly)
{
fileInfo.IsReadOnly = false;
}

File.Delete(file);
fileDeleted = true;
break;
}
catch (UnauthorizedAccessException)
{
// File is in use or access denied, wait and retry
if (attempt < maxRetries)
{
System.Threading.Thread.Sleep(retryDelayMs);
}
}
catch (IOException)
{
// File is in use, wait and retry
if (attempt < maxRetries)
{
System.Threading.Thread.Sleep(retryDelayMs);
}
}
catch
{
// Other exceptions, don't retry
break;
}
}

if (!fileDeleted)
{
fullyDeleted = false;
}
}

// Then, try to delete all empty directories (from deepest to shallowest)
var directories = Directory.GetDirectories(path, "*", SearchOption.AllDirectories)
.OrderByDescending(d => d.Length) // Delete deeper directories first
.ToArray();

foreach (var directory in directories)
{
try
{
if (Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any())
{
Directory.Delete(directory, false);
}
}
catch
{
// If we can't delete an empty directory, mark as not fully deleted
fullyDeleted = false;
}
}

// Finally, try to delete the root directory itself
try
{
if (Directory.Exists(path) && !Directory.EnumerateFileSystemEntries(path).Any())
{
Directory.Delete(path, false);
}
else if (Directory.Exists(path))
{
fullyDeleted = false;
}
}
catch
{
fullyDeleted = false;
}
}
catch
{
fullyDeleted = false;
}

return fullyDeleted;
}

/// <summary>
/// Checks if a directory exists
/// </summary>
Expand Down
85 changes: 85 additions & 0 deletions Flow.Launcher.Test/FilesFoldersTest.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Flow.Launcher.Plugin.SharedCommands;
using NUnit.Framework;
using NUnit.Framework.Legacy;
using System.IO;

namespace Flow.Launcher.Test
{
Expand Down Expand Up @@ -50,5 +51,89 @@ public void GivenTwoPathsAreTheSame_WhenCheckPathContains_ThenShouldBeExpectedRe
{
ClassicAssert.AreEqual(expectedResult, FilesFolders.PathContains(parentPath, path, allowEqual: expectedResult));
}

[Test]
public void TryDeleteDirectoryRobust_WhenDirectoryDoesNotExist_ReturnsTrue()
{
// Arrange
string nonExistentPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());

// Act
bool result = FilesFolders.TryDeleteDirectoryRobust(nonExistentPath);

// Assert
ClassicAssert.IsTrue(result);
}

[Test]
public void TryDeleteDirectoryRobust_WhenDirectoryIsEmpty_DeletesSuccessfully()
{
// Arrange
string tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
Directory.CreateDirectory(tempDir);

// Act
bool result = FilesFolders.TryDeleteDirectoryRobust(tempDir);

// Assert
ClassicAssert.IsTrue(result);
ClassicAssert.IsFalse(Directory.Exists(tempDir));
}

[Test]
public void TryDeleteDirectoryRobust_WhenDirectoryHasFiles_DeletesSuccessfully()
{
// Arrange
string tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
Directory.CreateDirectory(tempDir);
File.WriteAllText(Path.Combine(tempDir, "test.txt"), "test content");

// Act
bool result = FilesFolders.TryDeleteDirectoryRobust(tempDir);

// Assert
ClassicAssert.IsTrue(result);
ClassicAssert.IsFalse(Directory.Exists(tempDir));
}

[Test]
public void TryDeleteDirectoryRobust_WhenDirectoryHasNestedStructure_DeletesSuccessfully()
{
// Arrange
string tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
Directory.CreateDirectory(tempDir);
string subDir1 = Path.Combine(tempDir, "SubDir1");
string subDir2 = Path.Combine(tempDir, "SubDir2");
Directory.CreateDirectory(subDir1);
Directory.CreateDirectory(subDir2);
File.WriteAllText(Path.Combine(subDir1, "file1.txt"), "content1");
File.WriteAllText(Path.Combine(subDir2, "file2.txt"), "content2");
File.WriteAllText(Path.Combine(tempDir, "root.txt"), "root content");

// Act
bool result = FilesFolders.TryDeleteDirectoryRobust(tempDir);

// Assert
ClassicAssert.IsTrue(result);
ClassicAssert.IsFalse(Directory.Exists(tempDir));
}

[Test]
public void TryDeleteDirectoryRobust_WhenFileIsReadOnly_RemovesAttributeAndDeletes()
{
// Arrange
string tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
Directory.CreateDirectory(tempDir);
string filePath = Path.Combine(tempDir, "readonly.txt");
File.WriteAllText(filePath, "readonly content");
File.SetAttributes(filePath, FileAttributes.ReadOnly);

// Act
bool result = FilesFolders.TryDeleteDirectoryRobust(tempDir);

// Assert
ClassicAssert.IsTrue(result);
ClassicAssert.IsFalse(Directory.Exists(tempDir));
}
Comment on lines +55 to +137
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test coverage doesn't include a test case for partial deletion scenarios where TryDeleteDirectoryRobust returns false. Consider adding a test that verifies the method returns false when file deletion fails (e.g., by mocking or using a test helper that simulates a locked file). This would ensure the core functionality that differentiates this method from Directory.Delete is properly tested.

Copilot uses AI. Check for mistakes.
}
}
Loading