From f87b115632810f8c377b924066c2c9b1d8cb7c1f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 04:19:19 +0000 Subject: [PATCH 1/8] Initial plan From 0de552f37b319f4477d54d4b71915fa42b9b1c23 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 04:22:28 +0000 Subject: [PATCH 2/8] Add robust directory deletion with retry logic for locked files Co-authored-by: Jack251970 <53996452+Jack251970@users.noreply.github.com> --- Flow.Launcher.Core/Plugin/PluginConfig.cs | 7 +- .../SharedCommands/FilesFolders.cs | 113 ++++++++++++++++++ Flow.Launcher.Test/FilesFoldersTest.cs | 85 +++++++++++++ 3 files changed, 204 insertions(+), 1 deletion(-) diff --git a/Flow.Launcher.Core/Plugin/PluginConfig.cs b/Flow.Launcher.Core/Plugin/PluginConfig.cs index 4bf12faffee..21fe4211555 100644 --- a/Flow.Launcher.Core/Plugin/PluginConfig.cs +++ b/Flow.Launcher.Core/Plugin/PluginConfig.cs @@ -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 { @@ -30,7 +31,11 @@ public static List Parse(string[] pluginDirectories) { try { - Directory.Delete(directory, true); + var fullyDeleted = FilesFolders.TryDeleteDirectoryRobust(directory, maxRetries: 3, retryDelayMs: 100); + if (!fullyDeleted) + { + PublicApi.Instance.LogWarn(ClassName, $"Directory <{directory}> was not fully deleted. Some files or folders may still remain."); + } } catch (Exception e) { diff --git a/Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs b/Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs index 3af57f00d53..cd1ddf983a0 100644 --- a/Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs +++ b/Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs @@ -130,6 +130,119 @@ public static void RemoveFolderIfExists(this string path, Func + /// 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. + /// + /// The directory path to delete + /// Maximum number of retry attempts for locked files (default: 3) + /// Delay in milliseconds between retries (default: 100ms) + /// True if directory was fully deleted, false if some items remain + 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++) + { + 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; + } + /// /// Checks if a directory exists /// diff --git a/Flow.Launcher.Test/FilesFoldersTest.cs b/Flow.Launcher.Test/FilesFoldersTest.cs index 2621fc2da1f..a63b59c3989 100644 --- a/Flow.Launcher.Test/FilesFoldersTest.cs +++ b/Flow.Launcher.Test/FilesFoldersTest.cs @@ -1,6 +1,7 @@ using Flow.Launcher.Plugin.SharedCommands; using NUnit.Framework; using NUnit.Framework.Legacy; +using System.IO; namespace Flow.Launcher.Test { @@ -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)); + } } } From f3f9d62cfd9effba6dcc11dfd4c6125d486c029d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 04:40:28 +0000 Subject: [PATCH 3/8] Recreate NeedDelete.txt marker when directory deletion is incomplete Co-authored-by: Jack251970 <53996452+Jack251970@users.noreply.github.com> --- Flow.Launcher.Core/Plugin/PluginConfig.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/Flow.Launcher.Core/Plugin/PluginConfig.cs b/Flow.Launcher.Core/Plugin/PluginConfig.cs index 21fe4211555..b5e3480ced8 100644 --- a/Flow.Launcher.Core/Plugin/PluginConfig.cs +++ b/Flow.Launcher.Core/Plugin/PluginConfig.cs @@ -34,7 +34,20 @@ public static List Parse(string[] pluginDirectories) var fullyDeleted = FilesFolders.TryDeleteDirectoryRobust(directory, maxRetries: 3, retryDelayMs: 100); if (!fullyDeleted) { - PublicApi.Instance.LogWarn(ClassName, $"Directory <{directory}> was not fully deleted. Some files or folders may still remain."); + // Directory was not fully deleted, recreate the marker file so deletion will be retried on next startup + try + { + var markerFilePath = Path.Combine(directory, DataLocation.PluginDeleteFile); + if (!File.Exists(markerFilePath)) + { + File.WriteAllText(markerFilePath, string.Empty); + } + } + catch + { + // If we can't create the marker file, at least log the warning + } + 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) From 7b3216b0fd7c47fcaebcf464fdc6cfba6a1303a6 Mon Sep 17 00:00:00 2001 From: Jack251970 <1160210343@qq.com> Date: Wed, 4 Feb 2026 12:51:51 +0800 Subject: [PATCH 4/8] Simplify marker file recreation logic on plugin deletion Refactored the process for recreating the marker file when a plugin directory is not fully deleted. Removed unnecessary try-catch and nested checks, now directly checking for file existence and creating it if missing. This streamlines the code and removes redundant error handling. --- Flow.Launcher.Core/Plugin/PluginConfig.cs | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/Flow.Launcher.Core/Plugin/PluginConfig.cs b/Flow.Launcher.Core/Plugin/PluginConfig.cs index b5e3480ced8..5f129281bee 100644 --- a/Flow.Launcher.Core/Plugin/PluginConfig.cs +++ b/Flow.Launcher.Core/Plugin/PluginConfig.cs @@ -35,17 +35,10 @@ public static List Parse(string[] pluginDirectories) if (!fullyDeleted) { // Directory was not fully deleted, recreate the marker file so deletion will be retried on next startup - try + var markerFilePath = Path.Combine(directory, DataLocation.PluginDeleteFile); + if (!File.Exists(markerFilePath)) { - var markerFilePath = Path.Combine(directory, DataLocation.PluginDeleteFile); - if (!File.Exists(markerFilePath)) - { - File.WriteAllText(markerFilePath, string.Empty); - } - } - catch - { - // If we can't create the marker file, at least log the warning + 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."); } From 4e9c302ffb72d1ca53633a8e94644b3a4c67670f Mon Sep 17 00:00:00 2001 From: Jack251970 <1160210343@qq.com> Date: Sat, 7 Feb 2026 18:31:15 +0800 Subject: [PATCH 5/8] Update log message for incomplete directory deletion Simplified the warning log when a directory is not fully deleted, removing extra details about remaining files and retrying deletion. Marker file recreation logic is unchanged. --- Flow.Launcher.Core/Plugin/PluginConfig.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Flow.Launcher.Core/Plugin/PluginConfig.cs b/Flow.Launcher.Core/Plugin/PluginConfig.cs index 5f129281bee..db6813deb6b 100644 --- a/Flow.Launcher.Core/Plugin/PluginConfig.cs +++ b/Flow.Launcher.Core/Plugin/PluginConfig.cs @@ -31,16 +31,17 @@ public static List Parse(string[] pluginDirectories) { try { - var fullyDeleted = FilesFolders.TryDeleteDirectoryRobust(directory, maxRetries: 3, retryDelayMs: 100); + var fullyDeleted = FilesFolders.TryDeleteDirectoryRobust(directory, maxRetries: 3, retryDelayMs: 200); if (!fullyDeleted) { + PublicApi.Instance.LogWarn(ClassName, $"Directory <{directory}> was not fully deleted."); + // 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) From df5d5e70dd1c5ccb631b2fc4cf5082207351781b Mon Sep 17 00:00:00 2001 From: Jack251970 <1160210343@qq.com> Date: Sun, 8 Feb 2026 15:13:36 +0800 Subject: [PATCH 6/8] Check marker file when installing plugins --- Flow.Launcher.Core/Plugin/PluginManager.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Flow.Launcher.Core/Plugin/PluginManager.cs b/Flow.Launcher.Core/Plugin/PluginManager.cs index 54712942c28..8c60e572af1 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -931,6 +931,11 @@ internal static bool InstallPlugin(UserPlugin plugin, string zipFilePath, bool c FilesFolders.CopyAll(pluginFolderPath, newPluginPath, (s) => PublicApi.Instance.ShowMsgBox(s)); + // Check if marker file exists and delete it + var markerFilePath = Path.Combine(newPluginPath, DataLocation.PluginDeleteFile); + if (File.Exists(markerFilePath)) + File.Delete(markerFilePath); + try { if (Directory.Exists(tempFolderPluginPath)) From 2f6cde537df75c5a0543b9ca780ace5389ec7092 Mon Sep 17 00:00:00 2001 From: Jack251970 <1160210343@qq.com> Date: Sun, 8 Feb 2026 15:17:35 +0800 Subject: [PATCH 7/8] Add try-catch sentence when deleting mark file --- Flow.Launcher.Core/Plugin/PluginManager.cs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/Flow.Launcher.Core/Plugin/PluginManager.cs b/Flow.Launcher.Core/Plugin/PluginManager.cs index 8c60e572af1..4c2aca63136 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -932,9 +932,16 @@ internal static bool InstallPlugin(UserPlugin plugin, string zipFilePath, bool c FilesFolders.CopyAll(pluginFolderPath, newPluginPath, (s) => PublicApi.Instance.ShowMsgBox(s)); // Check if marker file exists and delete it - var markerFilePath = Path.Combine(newPluginPath, DataLocation.PluginDeleteFile); - if (File.Exists(markerFilePath)) - File.Delete(markerFilePath); + try + { + var markerFilePath = Path.Combine(newPluginPath, DataLocation.PluginDeleteFile); + if (File.Exists(markerFilePath)) + File.Delete(markerFilePath); + } + catch (Exception e) + { + PublicApi.Instance.LogException(ClassName, $"Failed to delete delete mark file", e); + } try { From 63d808b8cb32714d885ffd4b5df36e8b0a92593d Mon Sep 17 00:00:00 2001 From: Jack Ye <1160210343@qq.com> Date: Sun, 8 Feb 2026 15:21:30 +0800 Subject: [PATCH 8/8] Remove duplicated delete Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- Flow.Launcher.Core/Plugin/PluginManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Flow.Launcher.Core/Plugin/PluginManager.cs b/Flow.Launcher.Core/Plugin/PluginManager.cs index 4c2aca63136..b808e2a7fbd 100644 --- a/Flow.Launcher.Core/Plugin/PluginManager.cs +++ b/Flow.Launcher.Core/Plugin/PluginManager.cs @@ -940,7 +940,7 @@ internal static bool InstallPlugin(UserPlugin plugin, string zipFilePath, bool c } catch (Exception e) { - PublicApi.Instance.LogException(ClassName, $"Failed to delete delete mark file", e); + PublicApi.Instance.LogException(ClassName, $"Failed to delete plugin marker file in {newPluginPath}", e); } try