From 48f31481733382d7686ec11a3a1ff2067e18785d Mon Sep 17 00:00:00 2001 From: gtxPrime Date: Mon, 26 Oct 2026 17:35:50 +0530 Subject: [PATCH 1/8] feat: auto-convert files on extension change with confirmation setting --- global.json | 5 +- src/Filee.App/App.axaml.cs | 2 + src/Filee.App/Assets/i18n/en.json | 9 + src/Filee.App/Services/AppHost.cs | 1 + .../Services/ExtensionChangeService.cs | 159 +++++++ src/Filee.App/Services/WindowService.cs | 23 + .../ViewModels/Pages/GeneralPageViewModel.cs | 12 + .../Views/ExtensionChangeConfirmWindow.axaml | 25 + .../ExtensionChangeConfirmWindow.axaml.cs | 34 ++ src/Filee.App/Views/Pages/GeneralPage.axaml | 13 + src/Filee.Core/Settings/AppSettings.cs | 12 + .../Watching/ExtensionChangeWatcher.cs | 445 ++++++++++++++++++ .../ExtensionChangeSettingsTests.cs | 56 +++ .../ExtensionChangeWatcherTests.cs | 218 +++++++++ 14 files changed, 1012 insertions(+), 2 deletions(-) create mode 100644 src/Filee.App/Services/ExtensionChangeService.cs create mode 100644 src/Filee.App/Views/ExtensionChangeConfirmWindow.axaml create mode 100644 src/Filee.App/Views/ExtensionChangeConfirmWindow.axaml.cs create mode 100644 src/Filee.Core/Watching/ExtensionChangeWatcher.cs create mode 100644 tests/Filee.App.Tests/ExtensionChangeSettingsTests.cs create mode 100644 tests/Filee.Core.Tests/ExtensionChangeWatcherTests.cs diff --git a/global.json b/global.json index 512142d2b..33b94ff50 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,7 @@ { "sdk": { - "version": "10.0.100", - "rollForward": "latestFeature" + "version": "10.0.100-rc.2.25502.107", + "rollForward": "latestFeature", + "allowPrerelease": true } } diff --git a/src/Filee.App/App.axaml.cs b/src/Filee.App/App.axaml.cs index 688873860..28ae0769c 100644 --- a/src/Filee.App/App.axaml.cs +++ b/src/Filee.App/App.axaml.cs @@ -149,6 +149,7 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop) AppHost.Get().EnsureToast(); AppHost.Get().FeedbackDue += (_, _) => AppHost.Get().ShowFeedbackNotice(); AppHost.Get().Start(); + AppHost.Get().Start(); // Originals of files from a browser that a crash kept from being deleted ("Save only the converted file"). _ = Task.Run(() => Filee.Core.Web.WebDrop.DeleteStale(Filee.Core.Web.WebDrop.DefaultWorkRoot, TimeSpan.FromDays(1))); // Likewise the work folders of conversions that never got to clean up after themselves. @@ -171,6 +172,7 @@ private void Start(IClassicDesktopStyleApplicationLifetime desktop) AppHost.Get().Dispose(); AppHost.Get().Dispose(); AppHost.Get().Dispose(); + AppHost.Get().DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(5)); AppHost.Get().DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(5)); }; diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index 5fd3954a9..ca91fe2fe 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -443,6 +443,15 @@ "general.browser_drops_only_converted_hint": "The original waits in a temporary folder and is deleted once it's converted.", "general.zip_geo": "Save every geospatial result as a ZIP", "general.zip_geo_hint": "Shapefile, GeoJSON, KML, GPX and the other geospatial results are packed into one ZIP per file, with every preset.\nOff: only presets with \"Save the result as a ZIP\" do it.", + "general.extension_change": "File extension rename", + "general.extension_change_hint": "Automatically convert a file to its new format when you change its extension (such as .png to .pdf).", + "general.convert_on_extension_change": "Convert files when extension is changed", + "general.confirm_extension_change": "Ask for confirmation before converting", + "general.confirm_extension_change_hint": "Show a dialog to confirm before converting the renamed file.", + "extension_confirm.title": "Convert file?", + "extension_confirm.text": "The file extension of \"{0}\" was changed from {1} to {2}. Would you like Filee to convert the file to {2}?", + "extension_confirm.convert": "Convert", + "extension_confirm.dont_ask": "Always convert directly without asking", "general.check_updates": "Check for updates automatically", "general.close_action": "When I close the window", "general.close.Ask": "Ask every time", diff --git a/src/Filee.App/Services/AppHost.cs b/src/Filee.App/Services/AppHost.cs index 2eb90c92a..5dd746abf 100644 --- a/src/Filee.App/Services/AppHost.cs +++ b/src/Filee.App/Services/AppHost.cs @@ -68,6 +68,7 @@ public static IServiceProvider Build(string? dataDirectory = null, UserDataStore services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Filee.App/Services/ExtensionChangeService.cs b/src/Filee.App/Services/ExtensionChangeService.cs new file mode 100644 index 000000000..505f1634c --- /dev/null +++ b/src/Filee.App/Services/ExtensionChangeService.cs @@ -0,0 +1,159 @@ +using Avalonia.Threading; +using Filee.Core.Conversion; +using Filee.Core.Platform; +using Filee.Core.Presets; +using Filee.Core.Settings; +using Filee.Core.Watching; +using Microsoft.Extensions.Logging; + +namespace Filee.App.Services; + +/// +/// Monitors file extension changes across the filesystem and automatically converts them +/// according to user settings. +/// +public sealed class ExtensionChangeService : IAsyncDisposable +{ + private readonly UserDataStore _store; + private readonly WindowService _windowService; + private readonly IPlatformServices _platform; + private readonly ILogger _log; + private readonly ExtensionChangeWatcher _watcher; + private bool _isWatching; + + public ExtensionChangeService( + UserDataStore store, + ConverterCatalog catalog, + ConversionService conversions, + WindowService windowService, + IPlatformServices platform, + ILogger log) + { + _store = store; + _windowService = windowService; + _platform = platform; + _log = log; + + _watcher = new ExtensionChangeWatcher( + () => catalog.CreatePlanner(), + async (sourcePath, targetFormat, outputPath, ct) => + { + var preset = new Preset { TargetFormat = targetFormat }; + preset.Output.Location = OutputLocation.CustomFolder; + preset.Output.CustomFolder = Path.GetDirectoryName(outputPath) ?? ""; + preset.Output.Conflict = ConflictPolicy.Overwrite; + var job = await conversions.RunAsync([sourcePath], preset).ConfigureAwait(false); + return job is not null && job.Files.Any(f => f.State == FileState.Done); + }, + log); + + _watcher.ExtensionChangeDetected += OnExtensionChangeDetectedAsync; + } + + public void Start() + { + UpdateWatcherState(); + _store.SettingsChanged += (_, _) => UpdateWatcherState(); + } + + private void UpdateWatcherState() + { + var enabled = _store.Settings.ConvertOnExtensionChange; + if (enabled && !_isWatching) + { + var roots = GetWatchRoots(); + _watcher.Start(roots); + _isWatching = true; + _log.LogInformation("Extension change auto-conversion started on {Count} roots", roots.Count); + } + else if (!enabled && _isWatching) + { + _watcher.Stop(); + _isWatching = false; + _log.LogInformation("Extension change auto-conversion stopped"); + } + } + + private IReadOnlyList GetWatchRoots() + { + var roots = new HashSet(FileSystemPaths.Comparer); + + if (OperatingSystem.IsWindows()) + { + try + { + foreach (var drive in DriveInfo.GetDrives()) + { + if (drive.DriveType == DriveType.Fixed && drive.IsReady) + roots.Add(drive.RootDirectory.FullName); + } + } + catch (Exception ex) + { + _log.LogWarning(ex, "Could not enumerate fixed drives for extension change monitoring"); + } + } + + if (roots.Count == 0) + { + var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrWhiteSpace(userProfile)) + roots.Add(userProfile); + } + + // Standard user locations + var desktop = Environment.GetFolderPath(Environment.SpecialFolder.Desktop); + if (!string.IsNullOrWhiteSpace(desktop)) + roots.Add(desktop); + + var docs = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + if (!string.IsNullOrWhiteSpace(docs)) + roots.Add(docs); + + if (!string.IsNullOrWhiteSpace(_platform.DownloadsFolder)) + roots.Add(_platform.DownloadsFolder); + + // User watch folders + foreach (var rule in _store.Settings.WatchFolders) + { + if (!string.IsNullOrWhiteSpace(rule.Folder) && Directory.Exists(rule.Folder)) + roots.Add(rule.Folder); + } + + return roots.Where(Directory.Exists).ToList(); + } + + private async Task OnExtensionChangeDetectedAsync(ExtensionChangeCandidate candidate) + { + if (!_store.Settings.ConvertOnExtensionChange) + return; + + if (_store.Settings.ConfirmExtensionChange) + { + var confirmed = await Dispatcher.UIThread.InvokeAsync(async () => + { + return await _windowService.ConfirmExtensionChangeAsync( + candidate.FileName, + candidate.SourceFormat.DisplayName, + candidate.TargetFormat.DisplayName); + }); + + if (!confirmed) + { + _log.LogInformation("User cancelled extension conversion for {File}", candidate.NewPath); + return; + } + } + + _log.LogInformation("Executing extension conversion for {File} from {Source} to {Target}", + candidate.NewPath, candidate.SourceFormat.Id, candidate.TargetFormat.Id); + + await candidate.PerformConversionAsync(CancellationToken.None).ConfigureAwait(false); + } + + public async ValueTask DisposeAsync() + { + _store.SettingsChanged -= (_, _) => UpdateWatcherState(); + await _watcher.DisposeAsync(); + } +} diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs index 90a21e0f0..d448f799a 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -118,6 +118,28 @@ public sealed class WindowService(ILocalizer loc, IServiceProvider services, Mem } } + /// Asks the user whether they want to convert a file whose extension was changed. + public async Task ConfirmExtensionChangeAsync(string fileName, string sourceFormat, string targetFormat) + { + var message = loc.Format("extension_confirm.text", fileName, sourceFormat, targetFormat); + var dialog = new ExtensionChangeConfirmWindow(message); + PrepareWindow(dialog); + var closed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + dialog.Closed += (_, _) => + { + if (dialog.DontAskAgain) + { + var store = services.GetRequiredService(); + store.Settings.ConfirmExtensionChange = false; + store.SaveSettings(); + } + closed.TrySetResult(dialog.Tag as bool? == true); + }; + dialog.Show(); + dialog.Activate(); + return await closed.Task; + } + public MainWindow? Main => _main; /// @@ -699,3 +721,4 @@ public void ApplyTo(MainWindow window) } } } + diff --git a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs index c55d35401..1886accd5 100644 --- a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs @@ -50,6 +50,8 @@ public GeneralPageViewModel(UserDataStore store, ILocalizer loc, IPlatformServic _browserFolder = store.Settings.BrowserDrops.Folder; _saveOnlyConverted = store.Settings.BrowserDrops.SaveOnlyConverted; _zipGeoResults = store.Settings.ZipGeoResults; + _convertOnExtensionChange = store.Settings.ConvertOnExtensionChange; + _confirmExtensionChange = store.Settings.ConfirmExtensionChange; CloseChoices = Enum.GetValues().Select(v => new Choice(v, loc[$"general.close.{v}"])).ToList(); _closeChoice = CloseChoices.First(c => c.Value == store.Settings.CloseAction); store.SettingsChanged += OnSettingsChanged; @@ -120,11 +122,21 @@ partial void OnCloseChoiceChanged(Choice? oldValue, Choice s.CloseAction = newValue.Value); } + [ObservableProperty] private bool _convertOnExtensionChange; + [ObservableProperty] private bool _confirmExtensionChange; + + partial void OnConvertOnExtensionChangeChanged(bool value) => Save(s => s.ConvertOnExtensionChange = value); + partial void OnConfirmExtensionChangeChanged(bool value) => Save(s => s.ConfirmExtensionChange = value); + // The close dialog may answer "Don't ask again" while this page stays open behind the hidden window. private void OnSettingsChanged(object? sender, EventArgs e) { if (CloseChoice.Value != _store.Settings.CloseAction) CloseChoice = CloseChoices.First(c => c.Value == _store.Settings.CloseAction); + if (ConvertOnExtensionChange != _store.Settings.ConvertOnExtensionChange) + ConvertOnExtensionChange = _store.Settings.ConvertOnExtensionChange; + if (ConfirmExtensionChange != _store.Settings.ConfirmExtensionChange) + ConfirmExtensionChange = _store.Settings.ConfirmExtensionChange; } public void Dispose() => _store.SettingsChanged -= OnSettingsChanged; diff --git a/src/Filee.App/Views/ExtensionChangeConfirmWindow.axaml b/src/Filee.App/Views/ExtensionChangeConfirmWindow.axaml new file mode 100644 index 000000000..feb2adc4b --- /dev/null +++ b/src/Filee.App/Views/ExtensionChangeConfirmWindow.axaml @@ -0,0 +1,25 @@ + + + + + + + + + public double UiScale { get; set; } = 1; + /// + /// When a user renames a file by changing its extension (e.g. xyz.png -> xyz.pdf), convert the file + /// to the new format if supported. + /// + public bool ConvertOnExtensionChange { get; set; } + + /// + /// When is on, ask for confirmation before converting the renamed file. + /// When false, convert directly. + /// + public bool ConfirmExtensionChange { get; set; } = true; + /// /// Returns itself, or a copy with the app-wide output settings applied: it saves to /// when the preset's location is , and keeps the diff --git a/src/Filee.Core/Watching/ExtensionChangeWatcher.cs b/src/Filee.Core/Watching/ExtensionChangeWatcher.cs new file mode 100644 index 000000000..3a408ab17 --- /dev/null +++ b/src/Filee.Core/Watching/ExtensionChangeWatcher.cs @@ -0,0 +1,445 @@ +// Detects when a user renames a file to a different extension (e.g. xyz.png -> xyz.pdf), +// validates the file signature, checks that a conversion route exists, waits for OS locks to settle, +// and safely converts the file while avoiding infinite loops. + +using System.Collections.Concurrent; +using Filee.Core.Conversion; +using Filee.Core.Formats; +using Filee.Core.Platform; +using Filee.Core.Web; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Filee.Core.Watching; + +/// Details of a detected file extension change candidate ready for conversion. +public sealed class ExtensionChangeCandidate +{ + public required string OldPath { get; init; } + public required string NewPath { get; init; } + public required FileFormat SourceFormat { get; init; } + public required FileFormat TargetFormat { get; init; } + public required string FileName { get; init; } + public required Func> PerformConversionAsync { get; init; } +} + +/// +/// Cross-platform watcher that monitors directory roots for file extension changes. +/// +public sealed class ExtensionChangeWatcher : IAsyncDisposable +{ + private static readonly TimeSpan DefaultSettleTimeout = TimeSpan.FromSeconds(OperatingSystem.IsWindows() ? 3 : 5); + private static readonly TimeSpan InProgressTtl = TimeSpan.FromSeconds(20); + + private readonly Func _createPlanner; + private readonly Func> _convertFile; + private readonly ILogger _log; + private readonly TimeSpan _settleTimeout; + private readonly List _watchers = []; + private readonly ConcurrentDictionary _inProgressOperations = new(FileSystemPaths.Comparer); + private readonly Lock _lock = new(); + private bool _disposed; + + /// + /// Raised when a valid extension change is detected and settled. + /// Handlers can inspect the candidate and either trigger conversion directly or prompt the user. + /// + public event Func? ExtensionChangeDetected; + + /// Factory for route planner to test whether conversion is possible. + /// Delegate: (sourceFile, targetFormat, outputFile, ct) -> bool success. + /// Logger instance. + /// Maximum time to wait for the renamed file to unlock and settle. + public ExtensionChangeWatcher( + Func createPlanner, + Func> convertFile, + ILogger? log = null, + TimeSpan? settleTimeout = null) + { + _createPlanner = createPlanner; + _convertFile = convertFile; + _log = log ?? NullLogger.Instance; + _settleTimeout = settleTimeout ?? DefaultSettleTimeout; + } + + /// Marks a path as actively modified by Filee so filesystem events on it are ignored. + public void MarkInProgress(string path) + { + _inProgressOperations[path] = DateTimeOffset.UtcNow.Add(InProgressTtl); + } + + /// Unmarks a path from the in-progress list. + public void UnmarkInProgress(string path) + { + _inProgressOperations.TryRemove(path, out _); + } + + /// True if the path is currently being modified by Filee or recently was. + public bool IsInProgress(string path) + { + PruneExpiredInProgress(); + return _inProgressOperations.ContainsKey(path); + } + + private void PruneExpiredInProgress() + { + var now = DateTimeOffset.UtcNow; + foreach (var (p, expiry) in _inProgressOperations) + { + if (expiry < now) + _inProgressOperations.TryRemove(p, out _); + } + } + + /// Starts watching the given folders (and their subdirectories). + public void Start(IEnumerable directories) + { + lock (_lock) + { + Stop(); + foreach (var dir in directories.Distinct(FileSystemPaths.Comparer)) + { + if (!Directory.Exists(dir)) + continue; + + try + { + var watcher = new FileSystemWatcher(dir) + { + IncludeSubdirectories = true, + NotifyFilter = NotifyFilters.FileName, + InternalBufferSize = 64 * 1024, + }; + watcher.Renamed += OnRenamed; + watcher.Error += (_, e) => + { + _log.LogWarning(e.GetException(), "Filesystem watcher error on {Directory}", dir); + }; + watcher.EnableRaisingEvents = true; + _watchers.Add(watcher); + _log.LogInformation("Watching extension changes in {Directory}", dir); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { + _log.LogWarning(ex, "Could not start extension watcher on {Directory}", dir); + } + } + } + } + + /// Stops all active directory watchers. + public void Stop() + { + lock (_lock) + { + foreach (var w in _watchers) + { + try + { + w.EnableRaisingEvents = false; + w.Dispose(); + } + catch + { + // Ignore disposal errors on teardown + } + } + _watchers.Clear(); + } + } + + private void OnRenamed(object sender, RenamedEventArgs e) + { + _ = HandleRenameAsync(e.OldFullPath, e.FullPath); + } + + /// + /// Processes a rename event. Can also be called directly in tests. + /// Returns the candidate if valid, or null if ignored. + /// + public async Task HandleRenameAsync(string oldPath, string newPath, CancellationToken cancellationToken = default) + { + if (_disposed) + return null; + + if (ShouldIgnorePath(oldPath) || ShouldIgnorePath(newPath)) + return null; + + if (IsInProgress(oldPath) || IsInProgress(newPath)) + return null; + + var oldExt = FormatRegistry.ExtensionOf(oldPath); + var newExt = FormatRegistry.ExtensionOf(newPath); + + if (string.IsNullOrWhiteSpace(oldExt) || string.IsNullOrWhiteSpace(newExt)) + return null; + + if (string.Equals(oldExt, newExt, StringComparison.OrdinalIgnoreCase)) + return null; + + var sourceFormat = FormatRegistry.FindByExtension(oldExt); + var targetFormat = FormatRegistry.FindByExtension(newExt); + + if (sourceFormat is null || targetFormat is null) + return null; + + if (string.Equals(sourceFormat.Id, targetFormat.Id, StringComparison.OrdinalIgnoreCase)) + return null; + + if (!targetFormat.Writable) + return null; + + // Check if a conversion route exists between source and target format + var planner = _createPlanner(); + if (planner.Plan(sourceFormat.Id, targetFormat.Id) is null) + return null; + + // Wait until the file is accessible and unlocked + if (!await WaitForFileReadyAsync(newPath, _settleTimeout, cancellationToken).ConfigureAwait(false)) + return null; + + // Read file header to inspect actual file signature + var head = ReadHeader(newPath, WebFormats.SniffLength); + if (head.Length == 0) + return null; + + var sniffed = WebFormats.Sniff(head); + if (sniffed is not null) + { + // If the file already matches the target format internally (e.g. already a PDF), don't convert! + if (string.Equals(sniffed, targetFormat.Id, StringComparison.OrdinalIgnoreCase)) + return null; + + // If sniffed format is known and different from source, check if route from sniffed format to target exists + if (!string.Equals(sniffed, sourceFormat.Id, StringComparison.OrdinalIgnoreCase)) + { + var sniffedFormat = FormatRegistry.FindByExtension(sniffed); + if (sniffedFormat is not null) + { + if (planner.Plan(sniffedFormat.Id, targetFormat.Id) is null) + return null; + + sourceFormat = sniffedFormat; + } + } + } + + var fileName = Path.GetFileName(newPath); + var candidate = new ExtensionChangeCandidate + { + OldPath = oldPath, + NewPath = newPath, + SourceFormat = sourceFormat, + TargetFormat = targetFormat, + FileName = fileName, + PerformConversionAsync = ct => ConvertSafelyAsync(newPath, sourceFormat, targetFormat, ct), + }; + + if (ExtensionChangeDetected is { } handler) + { + await handler.Invoke(candidate).ConfigureAwait(false); + } + + return candidate; + } + + /// + /// Executes a safe conversion: writes to a temporary output file, validates it, + /// and replaces the destination file atomically while preserving the original on failure. + /// + public async Task ConvertSafelyAsync( + string filePath, + FileFormat sourceFormat, + FileFormat targetFormat, + CancellationToken cancellationToken = default) + { + if (!File.Exists(filePath)) + return false; + + var workDir = Path.Combine(Path.GetTempPath(), "Filee-ExtensionChange", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workDir); + + var tempSource = Path.Combine(workDir, $"{FormatRegistry.NameWithoutExtension(filePath)}.{sourceFormat.Id}"); + var tempOutput = Path.Combine(workDir, $"{FormatRegistry.NameWithoutExtension(filePath)}.{targetFormat.Id}"); + + MarkInProgress(filePath); + MarkInProgress(tempSource); + MarkInProgress(tempOutput); + + try + { + // Copy source to temporary working location with correct source extension + File.Copy(filePath, tempSource, overwrite: true); + + var success = await _convertFile(tempSource, targetFormat.Id, tempOutput, cancellationToken).ConfigureAwait(false); + if (!success || !File.Exists(tempOutput)) + { + _log.LogWarning("Conversion of {File} to {Target} failed", filePath, targetFormat.Id); + return false; + } + + var outputInfo = new FileInfo(tempOutput); + if (outputInfo.Length == 0) + { + _log.LogWarning("Converted output for {File} to {Target} is empty", filePath, targetFormat.Id); + return false; + } + + // Verify output signature if sniffable + var outputHead = ReadHeader(tempOutput, WebFormats.SniffLength); + var outputSniffed = WebFormats.Sniff(outputHead); + if (outputSniffed is not null && !string.Equals(outputSniffed, targetFormat.Id, StringComparison.OrdinalIgnoreCase)) + { + _log.LogWarning("Converted output signature for {File} does not match {Target}", filePath, targetFormat.Id); + return false; + } + + // Atomically replace destination file with converted output + File.Move(tempOutput, filePath, overwrite: true); + _log.LogInformation("Successfully converted {File} from {Source} to {Target}", filePath, sourceFormat.Id, targetFormat.Id); + return true; + } + catch (Exception ex) + { + _log.LogError(ex, "Failed to safely convert extension change on {File}", filePath); + return false; + } + finally + { + UnmarkInProgress(tempSource); + UnmarkInProgress(tempOutput); + // Give a brief window before unmarking destination to ensure OS file system events finish settling + _ = Task.Delay(1000).ContinueWith(_ => + { + UnmarkInProgress(filePath); + try + { + if (Directory.Exists(workDir)) + Directory.Delete(workDir, recursive: true); + } + catch + { + // Ignore temp dir cleanup errors + } + }); + } + } + + /// Checks whether a path belongs to system, temp, or ignored folders. + public static bool ShouldIgnorePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return true; + + var name = Path.GetFileName(path); + if (string.IsNullOrWhiteSpace(name)) + return true; + + // Temporary or system files + if (name.StartsWith('.') || name.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase) + || name.EndsWith(".temp", StringComparison.OrdinalIgnoreCase) + || name.EndsWith(".crdownload", StringComparison.OrdinalIgnoreCase) + || name.EndsWith(".part", StringComparison.OrdinalIgnoreCase) + || name.EndsWith(".download", StringComparison.OrdinalIgnoreCase) + || name.Contains(".filee-", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "desktop.ini", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "thumbs.db", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var isTemp = path.StartsWith(Path.GetTempPath(), StringComparison.OrdinalIgnoreCase); + + // Ignored system and build directories + var parts = path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + foreach (var part in parts) + { + if (part.StartsWith('.') && part.Length > 1) + return true; + if ((!isTemp && part.Equals("AppData", StringComparison.OrdinalIgnoreCase)) + || part.Equals("node_modules", StringComparison.OrdinalIgnoreCase) + || part.Equals("bin", StringComparison.OrdinalIgnoreCase) + || part.Equals("obj", StringComparison.OrdinalIgnoreCase) + || part.Equals("$Recycle.Bin", StringComparison.OrdinalIgnoreCase) + || part.Equals("System Volume Information", StringComparison.OrdinalIgnoreCase) + || part.Equals("Windows", StringComparison.OrdinalIgnoreCase) + || (!isTemp && part.Equals("Library", StringComparison.OrdinalIgnoreCase))) + { + return true; + } + } + + return false; + } + + /// Waits for a file to become accessible, non-empty, and stable in size. + public static async Task WaitForFileReadyAsync(string path, TimeSpan timeout, CancellationToken cancellationToken = default) + { + var deadline = DateTime.UtcNow + timeout; + long lastLength = -1; + var stableCount = 0; + + while (DateTime.UtcNow < deadline && !cancellationToken.IsCancellationRequested) + { + try + { + if (!File.Exists(path)) + return false; + + var info = new FileInfo(path); + var length = info.Length; + + using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + if (length == lastLength && length > 0) + { + stableCount++; + if (stableCount >= 2) + return true; + } + else if (length > 0) + { + lastLength = length; + stableCount = 1; + } + } + } + catch (IOException) + { + stableCount = 0; + } + catch (UnauthorizedAccessException) + { + stableCount = 0; + } + + await Task.Delay(50, cancellationToken).ConfigureAwait(false); + } + + return File.Exists(path) && new FileInfo(path).Length > 0; + } + + private static byte[] ReadHeader(string path, int maxBytes) + { + try + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + var buffer = new byte[Math.Min(maxBytes, (int)Math.Min(stream.Length, maxBytes))]; + var read = stream.Read(buffer, 0, buffer.Length); + if (read < buffer.Length) + Array.Resize(ref buffer, read); + return buffer; + } + catch + { + return []; + } + } + + public ValueTask DisposeAsync() + { + _disposed = true; + Stop(); + return ValueTask.CompletedTask; + } +} diff --git a/tests/Filee.App.Tests/ExtensionChangeSettingsTests.cs b/tests/Filee.App.Tests/ExtensionChangeSettingsTests.cs new file mode 100644 index 000000000..36fb18c27 --- /dev/null +++ b/tests/Filee.App.Tests/ExtensionChangeSettingsTests.cs @@ -0,0 +1,56 @@ +using Filee.App.ViewModels.Pages; +using Filee.Core.Localization; +using Filee.Core.Platform; +using Filee.Core.Settings; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Filee.App.Tests; + +public sealed class ExtensionChangeSettingsTests +{ + private sealed class MockLocalizer : ILocalizer + { + public string Language => "en"; + public event EventHandler? LanguageChanged; + public string this[string key] => key; + public string Format(string key, params object[] args) => string.Format(key, args); + } + + [Fact] + public void Settings_defaults_and_toggle_behavior() + { + var tempDir = Path.Combine(Path.GetTempPath(), "FileeSettingsTest-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var store = new UserDataStore(tempDir, NullLogger.Instance); + Assert.False(store.Settings.ConvertOnExtensionChange); + Assert.True(store.Settings.ConfirmExtensionChange); + + var loc = new MockLocalizer(); + var vm = new GeneralPageViewModel(store, loc, new NullPlatformServices(), NullLogger.Instance); + + Assert.False(vm.ConvertOnExtensionChange); + Assert.True(vm.ConfirmExtensionChange); + + // Toggle ConvertOnExtensionChange + vm.ConvertOnExtensionChange = true; + Assert.True(store.Settings.ConvertOnExtensionChange); + + // Toggle ConfirmExtensionChange + vm.ConfirmExtensionChange = false; + Assert.False(store.Settings.ConfirmExtensionChange); + + // React to external change (e.g. from dialog) + store.Settings.ConfirmExtensionChange = true; + store.SaveSettings(); + + Assert.True(vm.ConfirmExtensionChange); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } +} diff --git a/tests/Filee.Core.Tests/ExtensionChangeWatcherTests.cs b/tests/Filee.Core.Tests/ExtensionChangeWatcherTests.cs new file mode 100644 index 000000000..3bc512ce4 --- /dev/null +++ b/tests/Filee.Core.Tests/ExtensionChangeWatcherTests.cs @@ -0,0 +1,218 @@ +using Filee.Core.Conversion; +using Filee.Core.Formats; +using Filee.Core.Watching; +using Xunit; + +namespace Filee.Core.Tests; + +public sealed class ExtensionChangeWatcherTests +{ + private static readonly byte[] PngHeader = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00]; + private static readonly byte[] PdfHeader = "%PDF-1.7 header mock content"u8.ToArray(); + + private sealed class DummyConverter : IConverter + { + public string Id => "dummy"; + public string DisplayName => "Dummy"; + public IReadOnlyList Edges => + [ + new("png", "pdf", 1), + new("jpg", "png", 1), + new("png", "webp", 1), + ]; + + public int MaxParallelism => 0; + public EngineStatus GetStatus() => new(true); + public Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) => + Task.FromResult>([step.InputPath]); + } + + private static RoutePlanner CreatePlanner() => + new([new DummyConverter()]); + + [Fact] + public void ShouldIgnorePath_filters_system_and_temp_files() + { + Assert.True(ExtensionChangeWatcher.ShouldIgnorePath(@"C:\folder\file.tmp")); + Assert.True(ExtensionChangeWatcher.ShouldIgnorePath(@"C:\folder\.hidden")); + Assert.True(ExtensionChangeWatcher.ShouldIgnorePath(@"C:\folder\file.crdownload")); + Assert.True(ExtensionChangeWatcher.ShouldIgnorePath(@"C:\folder\test.filee-conv.tmp")); + Assert.True(ExtensionChangeWatcher.ShouldIgnorePath(@"C:\folder\desktop.ini")); + Assert.True(ExtensionChangeWatcher.ShouldIgnorePath(@"C:\AppData\Local\file.png")); + Assert.True(ExtensionChangeWatcher.ShouldIgnorePath(@"C:\repo\.git\hooks\file.png")); + Assert.True(ExtensionChangeWatcher.ShouldIgnorePath(@"C:\repo\bin\Debug\file.png")); + + Assert.False(ExtensionChangeWatcher.ShouldIgnorePath(@"C:\Users\User\Documents\report.pdf")); + Assert.False(ExtensionChangeWatcher.ShouldIgnorePath(@"/home/user/desktop/photo.png")); + } + + [Fact] + public async Task HandleRename_ignores_same_extension() + { + var watcher = new ExtensionChangeWatcher(CreatePlanner, (_, _, _, _) => Task.FromResult(true)); + var candidate = await watcher.HandleRenameAsync(@"C:\folder\photo1.png", @"C:\folder\photo2.png"); + Assert.Null(candidate); + } + + [Fact] + public async Task HandleRename_ignores_unsupported_target_format() + { + var watcher = new ExtensionChangeWatcher(CreatePlanner, (_, _, _, _) => Task.FromResult(true)); + var candidate = await watcher.HandleRenameAsync(@"C:\folder\photo.png", @"C:\folder\photo.xyzrandom"); + Assert.Null(candidate); + } + + [Fact] + public async Task HandleRename_ignores_when_no_conversion_route_exists() + { + // DummyConverter has no route from png to mp4 + var watcher = new ExtensionChangeWatcher(CreatePlanner, (_, _, _, _) => Task.FromResult(true)); + var candidate = await watcher.HandleRenameAsync(@"C:\folder\photo.png", @"C:\folder\photo.mp4"); + Assert.Null(candidate); + } + + [Fact] + public async Task HandleRename_ignores_paths_currently_in_progress() + { + var watcher = new ExtensionChangeWatcher(CreatePlanner, (_, _, _, _) => Task.FromResult(true)); + var path = @"C:\folder\busy.pdf"; + watcher.MarkInProgress(path); + + Assert.True(watcher.IsInProgress(path)); + var candidate = await watcher.HandleRenameAsync(@"C:\folder\busy.png", path); + Assert.Null(candidate); + + watcher.UnmarkInProgress(path); + Assert.False(watcher.IsInProgress(path)); + } + + [Fact] + public async Task HandleRename_ignores_file_if_already_matching_target_signature() + { + var dir = Path.Combine(Path.GetTempPath(), "FileeWatcherTest-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var oldPath = Path.Combine(dir, "doc.png"); + var newPath = Path.Combine(dir, "doc.pdf"); + + // Write PDF bytes even though old name was png + await File.WriteAllBytesAsync(newPath, PdfHeader); + + var watcher = new ExtensionChangeWatcher(CreatePlanner, (_, _, _, _) => Task.FromResult(true), settleTimeout: TimeSpan.FromSeconds(1)); + var candidate = await watcher.HandleRenameAsync(oldPath, newPath); + + // Sniff shows it is already a PDF internally, so it should NOT convert! + Assert.Null(candidate); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public async Task HandleRename_detects_valid_candidate_when_signature_is_png_and_target_is_pdf() + { + var dir = Path.Combine(Path.GetTempPath(), "FileeWatcherTest-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var oldPath = Path.Combine(dir, "doc.png"); + var newPath = Path.Combine(dir, "doc.pdf"); + + // Write PNG bytes (the user renamed doc.png to doc.pdf, so doc.pdf has PNG data inside) + await File.WriteAllBytesAsync(newPath, PngHeader); + + var detected = false; + var watcher = new ExtensionChangeWatcher( + CreatePlanner, + (_, _, _, _) => Task.FromResult(true), + settleTimeout: TimeSpan.FromSeconds(1)); + + watcher.ExtensionChangeDetected += c => + { + detected = true; + Assert.Equal("png", c.SourceFormat.Id); + Assert.Equal("pdf", c.TargetFormat.Id); + Assert.Equal("doc.pdf", c.FileName); + return Task.CompletedTask; + }; + + var candidate = await watcher.HandleRenameAsync(oldPath, newPath); + Assert.NotNull(candidate); + Assert.True(detected); + Assert.Equal("png", candidate.SourceFormat.Id); + Assert.Equal("pdf", candidate.TargetFormat.Id); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public async Task ConvertSafely_converts_and_atomically_replaces_target_file() + { + var dir = Path.Combine(Path.GetTempPath(), "FileeWatcherTest-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var filePath = Path.Combine(dir, "photo.pdf"); + await File.WriteAllBytesAsync(filePath, PngHeader); + + var watcher = new ExtensionChangeWatcher( + CreatePlanner, + async (_, _, output, _) => + { + // Mock conversion producing valid PDF + await File.WriteAllBytesAsync(output, PdfHeader); + return true; + }); + + var pngFormat = FormatRegistry.Get("png"); + var pdfFormat = FormatRegistry.Get("pdf"); + + var success = await watcher.ConvertSafelyAsync(filePath, pngFormat, pdfFormat); + Assert.True(success); + + // Verify file was replaced with PDF content + var finalBytes = await File.ReadAllBytesAsync(filePath); + Assert.Equal(PdfHeader, finalBytes); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public async Task ConvertSafely_preserves_original_file_if_conversion_fails() + { + var dir = Path.Combine(Path.GetTempPath(), "FileeWatcherTest-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var filePath = Path.Combine(dir, "photo.pdf"); + await File.WriteAllBytesAsync(filePath, PngHeader); + + var watcher = new ExtensionChangeWatcher( + CreatePlanner, + (_, _, _, _) => Task.FromResult(false)); // Conversion fails + + var pngFormat = FormatRegistry.Get("png"); + var pdfFormat = FormatRegistry.Get("pdf"); + + var success = await watcher.ConvertSafelyAsync(filePath, pngFormat, pdfFormat); + Assert.False(success); + + // Verify file was preserved with original PNG bytes + var finalBytes = await File.ReadAllBytesAsync(filePath); + Assert.Equal(PngHeader, finalBytes); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } +} From 1b2b6016c427f3594b910a7da78aa85355bd91f4 Mon Sep 17 00:00:00 2001 From: gtxPrime Date: Mon, 26 Oct 2026 17:48:18 +0530 Subject: [PATCH 2/8] i18n: add translations for extension change conversion settings and dialog --- src/Filee.App/Assets/i18n/ar.json | 9 ++++++ src/Filee.App/Assets/i18n/de.json | 9 ++++++ src/Filee.App/Assets/i18n/es.json | 9 ++++++ src/Filee.App/Assets/i18n/fr.json | 9 ++++++ src/Filee.App/Assets/i18n/hi.json | 9 ++++++ src/Filee.App/Assets/i18n/ja.json | 9 ++++++ src/Filee.App/Assets/i18n/ko.json | 9 ++++++ src/Filee.App/Assets/i18n/ne.json | 9 ++++++ src/Filee.App/Assets/i18n/pl.json | 9 ++++++ src/Filee.App/Assets/i18n/pt-BR.json | 9 ++++++ src/Filee.App/Assets/i18n/pt-PT.json | 9 ++++++ src/Filee.App/Assets/i18n/ru.json | 9 ++++++ src/Filee.App/Assets/i18n/th.json | 9 ++++++ src/Filee.App/Assets/i18n/tr.json | 9 ++++++ src/Filee.App/Assets/i18n/zh-CN.json | 9 ++++++ src/Filee.App/Assets/i18n/zh-TW.json | 9 ++++++ .../ExtensionChangeSettingsTests.cs | 2 +- .../ExtensionChangeWatcherTests.cs | 32 +++++++++---------- 18 files changed, 161 insertions(+), 17 deletions(-) diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index ab07dfc21..0c009b065 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -431,6 +431,15 @@ "general.browser_drops_only_converted_hint": "يبقى الأصل في مجلد مؤقت ويُحذف بعد اكتمال التحويل.", "general.zip_geo": "حفظ كل نتيجة جغرافية بصيغة ZIP", "general.zip_geo_hint": "تُجمَّع نتائج Shapefile وGeoJSON وKML وGPX وغيرها من البيانات الجغرافية في ZIP واحد لكل ملف، مع كل إعداد مسبق.\nعند التعطيل: يُطبَّق ذلك فقط على الإعدادات المسبقة التي تفعّل «حفظ النتيجة بصيغة ZIP».", + "general.extension_change": "تغيير امتداد الملف", + "general.extension_change_hint": "تحويل الملف تلقائيًا إلى صيغته الجديدة عند تغيير امتداده (مثل تغيير .png إلى .pdf).", + "general.convert_on_extension_change": "تحويل الملفات عند تغيير الامتداد", + "general.confirm_extension_change": "طلب التأكيد قبل التحويل", + "general.confirm_extension_change_hint": "إظهار مربع حوار للتأكيد قبل تحويل الملف المعاد تسميته.", + "extension_confirm.title": "تحويل الملف؟", + "extension_confirm.text": "تم تغيير امتداد الملف \"{0}\" من {1} إلى {2}. هل ترغب في أن يقوم Filee بتحويل الملف إلى {2}؟", + "extension_confirm.convert": "تحويل", + "extension_confirm.dont_ask": "التحويل دائمًا مباشرة دون السؤال مجددًا", "general.check_updates": "التحقق من التحديثات تلقائيًا", "general.close_action": "عند إغلاق النافذة", "general.close.Ask": "السؤال كل مرة", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index 436b9cc14..6b55f0289 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -431,6 +431,15 @@ "general.browser_drops_only_converted_hint": "Das Original bleibt in einem temporären Ordner und wird nach der Konvertierung gelöscht.", "general.zip_geo": "Alle Geodaten-Ergebnisse als ZIP speichern", "general.zip_geo_hint": "Shapefile, GeoJSON, KML, GPX und andere Geodaten-Ergebnisse werden bei jeder Vorlage in eine ZIP-Datei pro Datei gepackt.\nAusgeschaltet: Gilt nur für Vorlagen mit „Ergebnis als ZIP speichern“.", + "general.extension_change": "Dateiendung umbenennen", + "general.extension_change_hint": "Konvertiert eine Datei automatisch in das neue Format, wenn Sie ihre Dateiendung ändern (z. B. .png in .pdf).", + "general.convert_on_extension_change": "Dateien bei Änderung der Endung konvertieren", + "general.confirm_extension_change": "Vor dem Konvertieren nach Bestätigung fragen", + "general.confirm_extension_change_hint": "Einen Dialog zur Bestätigung anzeigen, bevor die umbenannte Datei konvertiert wird.", + "extension_confirm.title": "Datei konvertieren?", + "extension_confirm.text": "Die Dateiendung von „{0}“ wurde von {1} in {2} geändert. Möchten Sie, dass Filee die Datei in {2} konvertiert?", + "extension_confirm.convert": "Konvertieren", + "extension_confirm.dont_ask": "Immer direkt konvertieren, ohne nachzufragen", "general.check_updates": "Automatisch nach Updates suchen", "general.close_action": "Beim Schließen des Fensters", "general.close.Ask": "Jedes Mal fragen", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index ae43cfefc..98baafb07 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -431,6 +431,15 @@ "general.browser_drops_only_converted_hint": "El original espera en una carpeta temporal y se elimina en cuanto se convierte.", "general.zip_geo": "Guardar todo resultado geoespacial como ZIP", "general.zip_geo_hint": "Shapefile, GeoJSON, KML, GPX y los demás resultados geoespaciales se empaquetan en un ZIP por archivo, con cualquier preajuste.\nDesactivado: solo lo hacen los preajustes con «Guardar el resultado como ZIP».", + "general.extension_change": "Cambio de extensión de archivo", + "general.extension_change_hint": "Convierte automáticamente un archivo a su nuevo formato cuando cambias su extensión (como .png a .pdf).", + "general.convert_on_extension_change": "Convertir archivos al cambiar la extensión", + "general.confirm_extension_change": "Pedir confirmación antes de convertir", + "general.confirm_extension_change_hint": "Mostrar un cuadro de diálogo de confirmación antes de convertir el archivo renombrado.", + "extension_confirm.title": "¿Convertir archivo?", + "extension_confirm.text": "La extensión del archivo \"{0}\" se cambió de {1} a {2}. ¿Deseas que Filee convierta el archivo a {2}?", + "extension_confirm.convert": "Convertir", + "extension_confirm.dont_ask": "Convertir siempre directamente sin preguntar", "general.check_updates": "Buscar actualizaciones automáticamente", "general.close_action": "Al cerrar la ventana", "general.close.Ask": "Preguntar cada vez", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index d91360e12..a9df7e7d1 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -443,6 +443,15 @@ "general.browser_drops_only_converted_hint": "L'original attend dans un dossier temporaire et est supprimé une fois converti.", "general.zip_geo": "Enregistrer chaque résultat géospatial en ZIP", "general.zip_geo_hint": "Les résultats Shapefile, GeoJSON, KML, GPX et autres formats géospatiaux sont regroupés dans un ZIP par fichier, quel que soit le préréglage.\nDésactivé : seuls les préréglages avec « Enregistrer le résultat en ZIP » le font.", + "general.extension_change": "Renommage de l'extension de fichier", + "general.extension_change_hint": "Convertit automatiquement un fichier dans son nouveau format lorsque vous modifiez son extension (par exemple de .png en .pdf).", + "general.convert_on_extension_change": "Convertir les fichiers lorsque l'extension est modifiée", + "general.confirm_extension_change": "Demander confirmation avant de convertir", + "general.confirm_extension_change_hint": "Afficher une boîte de dialogue pour confirmer avant de convertir le fichier renommé.", + "extension_confirm.title": "Convertir le fichier ?", + "extension_confirm.text": "L'extension du fichier « {0} » a été modifiée de {1} en {2}. Souhaitez-vous que Filee convertisse le fichier en {2} ?", + "extension_confirm.convert": "Convertir", + "extension_confirm.dont_ask": "Toujours convertir directement sans demander", "general.check_updates": "Rechercher automatiquement les mises à jour", "general.close_action": "À la fermeture de la fenêtre", "general.close.Ask": "Toujours demander", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index fa612926b..30608260c 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -431,6 +431,15 @@ "general.browser_drops_only_converted_hint": "मूल फ़ाइल एक अस्थायी फ़ोल्डर में रहती है और कन्वर्ट होने के बाद हटा दी जाती है।", "general.zip_geo": "हर भू-स्थानिक नतीजे को ZIP के रूप में सहेजें", "general.zip_geo_hint": "हर प्रीसेट के साथ, Shapefile, GeoJSON, KML, GPX और दूसरे भू-स्थानिक नतीजे हर फ़ाइल के लिए एक ZIP में पैक किए जाते हैं।\nबंद होने पर: केवल वही प्रीसेट ऐसा करते हैं जिनमें \"नतीजे को ZIP के रूप में सहेजें\" चालू है।", + "general.extension_change": "फ़ाइल एक्सटेंशन का नाम बदलना", + "general.extension_change_hint": "फ़ाइल का एक्सटेंशन बदलने पर (जैसे .png से .pdf) उसे स्वचालित रूप से नए प्रारूप में बदलें।", + "general.convert_on_extension_change": "एक्सटेंशन बदलने पर फ़ाइलों को बदलें", + "general.confirm_extension_change": "बदलने से पहले पुष्टि के लिए पूछें", + "general.confirm_extension_change_hint": "पुनर्नामित फ़ाइल को बदलने से पहले पुष्टि संवाद दिखाएं।", + "extension_confirm.title": "फ़ाइल बदलें?", + "extension_confirm.text": "\"{0}\" का फ़ाइल एक्सटेंशन {1} से बदलकर {2} कर दिया गया है। क्या आप चाहते हैं कि Filee फ़ाइल को {2} में बदल दे?", + "extension_confirm.convert": "बदलें", + "extension_confirm.dont_ask": "बिना पूछे हमेशा सीधे बदलें", "general.check_updates": "अपडेट अपने-आप जाँचें", "general.close_action": "जब मैं विंडो बंद करूँ", "general.close.Ask": "हर बार पूछें", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 7fadc185e..18c47f31a 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -431,6 +431,15 @@ "general.browser_drops_only_converted_hint": "元ファイルは一時フォルダーに置かれ、変換が終わると削除されます。", "general.zip_geo": "地理空間データの変換結果はすべてZIPで保存する", "general.zip_geo_hint": "どのプリセットでも、Shapefile、GeoJSON、KML、GPXなどの地理空間データの変換結果をファイルごとに1つのZIPにまとめます。\nオフの場合は、「変換結果をZIPで保存」を有効にしたプリセットだけに適用されます。", + "general.extension_change": "ファイル拡張子の変更", + "general.extension_change_hint": "ファイルの拡張子を変更したとき(例: .png を .pdf に変更)、自動的に新しい形式に変換します。", + "general.convert_on_extension_change": "拡張子変更時にファイルを変換する", + "general.confirm_extension_change": "変換前に確認メッセージを表示する", + "general.confirm_extension_change_hint": "名前を変更したファイルを変換する前に確認ダイアログを表示します。", + "extension_confirm.title": "ファイルを変換しますか?", + "extension_confirm.text": "「{0}」の拡張子が {1} から {2} に変更されました。Filee でこのファイルを {2} に変換しますか?", + "extension_confirm.convert": "変換", + "extension_confirm.dont_ask": "確認せずに常に直接変換する", "general.check_updates": "更新を自動で確認", "general.close_action": "ウィンドウを閉じたとき", "general.close.Ask": "毎回確認", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index e81e22da1..832803164 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -443,6 +443,15 @@ "general.browser_drops_only_converted_hint": "원본은 임시 폴더에 두었다가 변환이 끝나면 지워요.", "general.zip_geo": "지리 정보 결과를 모두 ZIP으로 저장", "general.zip_geo_hint": "Shapefile, GeoJSON, KML, GPX 등 지리 정보 결과를 모든 프리셋에서 파일마다 ZIP 하나로 묶어요.\n꺼 두면 \"결과를 ZIP으로 저장\"을 켠 프리셋만 묶어요.", + "general.extension_change": "파일 확장자 이름 변경", + "general.extension_change_hint": "파일 확장자를 변경할 때(예: .png를 .pdf로) 자동으로 새 형식으로 변환합니다.", + "general.convert_on_extension_change": "확장자 변경 시 파일 변환", + "general.confirm_extension_change": "변환하기 전에 확인 메시지 표시", + "general.confirm_extension_change_hint": "이름이 바뀐 파일을 변환하기 전에 확인 대화 상자를 표시합니다.", + "extension_confirm.title": "파일을 변환할까요?", + "extension_confirm.text": "\"{0}\"의 파일 확장자가 {1}에서 {2}(으)로 변경되었습니다. Filee에서 파일을 {2}(으)로 변환할까요?", + "extension_confirm.convert": "변환", + "extension_confirm.dont_ask": "묻지 않고 항상 직접 변환", "general.check_updates": "자동으로 업데이트 확인", "general.close_action": "창을 닫을 때", "general.close.Ask": "매번 묻기", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index c258c7e9f..95257c177 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -431,6 +431,15 @@ "general.browser_drops_only_converted_hint": "मूल फाइल अस्थायी फोल्डरमा रहन्छ र रूपान्तरण भएपछि मेटाइन्छ।", "general.zip_geo": "प्रत्येक भौगोलिक परिणामलाई ZIP को रूपमा सुरक्षित गर्नुहोस्", "general.zip_geo_hint": "Shapefile, GeoJSON, KML, GPX र अन्य भौगोलिक परिणामहरू हरेक प्रिसेटसँग प्रति फाइल एउटा ZIP मा प्याक गरिन्छ।\nबन्द हुँदा: केवल \"परिणामलाई ZIP को रूपमा सुरक्षित गर्नुहोस्\" सक्षम भएका प्रिसेटहरूले मात्र यसो गर्छन्।", + "general.extension_change": "फाइल विस्तार पुन: नामाकरण", + "general.extension_change_hint": "फाइलको विस्तार परिवर्तन गर्दा (जस्तै .png बाट .pdf) स्वचालित रूपमा नयाँ ढाँचामा रूपान्तरण गर्नुहोस्।", + "general.convert_on_extension_change": "विस्तार परिवर्तन हुँदा फाइलहरू रूपान्तरण गर्नुहोस्", + "general.confirm_extension_change": "रूपान्तरण गर्नु अघि पुष्टिकरण माग्नुहोस्", + "general.confirm_extension_change_hint": "पुन: नामाकरण गरिएको फाइल रूपान्तरण गर्नु अघि पुष्टिकरण संवाद देखाउनुहोस्।", + "extension_confirm.title": "फाइल रूपान्तरण गर्ने?", + "extension_confirm.text": "\"{0}\" को फाइल विस्तार {1} बाट {2} मा परिवर्तन गरिएको थियो। के तपाईं Filee ले फाइललाई {2} मा रूपान्तरण गरोस् भन्ने चाहनुहुन्छ?", + "extension_confirm.convert": "रूपान्तरण गर्नुहोस्", + "extension_confirm.dont_ask": "सोधपुछ नगरी सधैं सिधै रूपान्तरण गर्नुहोस्", "general.check_updates": "स्वचालित रूपमा अपडेटहरू जाँच गर्नुहोस्", "general.close_action": "जब म विन्डो बन्द गर्छु", "general.close.Ask": "प्रत्येक पटक सोध्नुहोस्", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 8ce69b4b3..3f97c2bc8 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -443,6 +443,15 @@ "general.browser_drops_only_converted_hint": "Oryginał czeka w folderze tymczasowym i jest usuwany po konwersji.", "general.zip_geo": "Zapisuj każdy wynik geoprzestrzenny jako ZIP", "general.zip_geo_hint": "Shapefile, GeoJSON, KML, GPX i inne wyniki geoprzestrzenne są pakowane do jednego ZIP na plik, w każdym ustawieniu.\nWyłączone: robią to tylko ustawienia z opcją „Zapisz wynik jako ZIP”.", + "general.extension_change": "Zmiana rozszerzenia pliku", + "general.extension_change_hint": "Automatycznie konwertuj plik na nowy format po zmianie jego rozszerzenia (np. z .png na .pdf).", + "general.convert_on_extension_change": "Konwertuj pliki po zmianie rozszerzenia", + "general.confirm_extension_change": "Pytaj o potwierdzenie przed konwersją", + "general.confirm_extension_change_hint": "Wyświetl okno dialogowe z prośbą o potwierdzenie przed konwersją pliku o zmienionej nazwie.", + "extension_confirm.title": "Skonwertować plik?", + "extension_confirm.text": "Rozszerzenie pliku „{0}” zostało zmienione z {1} na {2}. Czy chcesz, aby Filee przekonwertowało ten plik na {2}?", + "extension_confirm.convert": "Konwertuj", + "extension_confirm.dont_ask": "Zawsze konwertuj bezpośrednio bez pytania", "general.check_updates": "Automatycznie sprawdzaj aktualizacje", "general.close_action": "Po zamknięciu okna", "general.close.Ask": "Pytaj za każdym razem", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 1292206f6..1eeef081f 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -443,6 +443,15 @@ "general.browser_drops_only_converted_hint": "O original fica numa pasta temporária e é apagado depois da conversão.", "general.zip_geo": "Salvar todo resultado geoespacial como ZIP", "general.zip_geo_hint": "Shapefile, GeoJSON, KML, GPX e os outros resultados geoespaciais são compactados num ZIP por arquivo, em toda predefinição.\nDesligado: só as predefinições com “Salvar o resultado como ZIP” fazem isso.", + "general.extension_change": "Renomeação de extensão de arquivo", + "general.extension_change_hint": "Converte automaticamente um arquivo para seu novo formato quando você altera sua extensão (como .png para .pdf).", + "general.convert_on_extension_change": "Converter arquivos ao alterar a extensão", + "general.confirm_extension_change": "Pedir confirmação antes de converter", + "general.confirm_extension_change_hint": "Exibir uma caixa de diálogo para confirmar antes de converter o arquivo renomeado.", + "extension_confirm.title": "Converter arquivo?", + "extension_confirm.text": "A extensão de \"{0}\" foi alterada de {1} para {2}. Deseja que o Filee converta o arquivo para {2}?", + "extension_confirm.convert": "Converter", + "extension_confirm.dont_ask": "Sempre converter diretamente sem perguntar", "general.check_updates": "Procurar atualizações automaticamente", "general.close_action": "Quando eu fechar a janela", "general.close.Ask": "Perguntar sempre", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index 48563251f..35bf140b6 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -443,6 +443,15 @@ "general.browser_drops_only_converted_hint": "O original fica numa pasta temporária e é eliminado após a conversão.", "general.zip_geo": "Guardar todos os resultados geoespaciais em ZIP", "general.zip_geo_hint": "Shapefile, GeoJSON, KML, GPX e os restantes resultados geoespaciais são comprimidos num ZIP por ficheiro, em todas as predefinições.\nDesativado: só o fazem as predefinições com «Guardar o resultado em ZIP».", + "general.extension_change": "Mudança de extensão de ficheiro", + "general.extension_change_hint": "Converte automaticamente um ficheiro para o seu novo formato quando altera a respetiva extensão (como de .png para .pdf).", + "general.convert_on_extension_change": "Converter ficheiros ao alterar a extensão", + "general.confirm_extension_change": "Pedir confirmação antes de converter", + "general.confirm_extension_change_hint": "Apresentar uma caixa de diálogo para confirmar antes de converter o ficheiro renomeado.", + "extension_confirm.title": "Converter ficheiro?", + "extension_confirm.text": "A extensão do ficheiro \"{0}\" foi alterada de {1} para {2}. Deseja que o Filee converta o ficheiro para {2}?", + "extension_confirm.convert": "Converter", + "extension_confirm.dont_ask": "Converter sempre diretamente sem perguntar", "general.check_updates": "Procurar atualizações automaticamente", "general.close_action": "Ao fechar a janela", "general.close.Ask": "Perguntar sempre", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 62243b38d..6653d90ec 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -443,6 +443,15 @@ "general.browser_drops_only_converted_hint": "Оригинал ждёт во временной папке и удаляется после конвертации.", "general.zip_geo": "Сохранять любой результат с геоданными в ZIP", "general.zip_geo_hint": "Shapefile, GeoJSON, KML, GPX и другие результаты с геоданными упаковываются в один ZIP на файл, с любым пресетом.\nВыключено: так делают только пресеты с включённым «Сохранять результат в ZIP».", + "general.extension_change": "Переименование расширения файла", + "general.extension_change_hint": "Автоматически конвертировать файл в новый формат при изменении его расширения (например, .png на .pdf).", + "general.convert_on_extension_change": "Конвертировать файлы при смене расширения", + "general.confirm_extension_change": "Запрашивать подтверждение перед конвертацией", + "general.confirm_extension_change_hint": "Показывать диалог подтверждения перед конвертацией переименованного файла.", + "extension_confirm.title": "Конвертировать файл?", + "extension_confirm.text": "Расширение файла «{0}» было изменено с {1} на {2}. Вы хотите, чтобы Filee конвертировал файл в {2}?", + "extension_confirm.convert": "Конвертировать", + "extension_confirm.dont_ask": "Всегда конвертировать напрямую без запроса", "general.check_updates": "Проверять обновления автоматически", "general.close_action": "Когда я закрываю окно", "general.close.Ask": "Спрашивать каждый раз", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index dcadcb3e8..17a40d905 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -431,6 +431,15 @@ "general.browser_drops_only_converted_hint": "ไฟล์ต้นฉบับจะถูกพักไว้ในโฟลเดอร์ชั่วคราวและจะถูกลบหลังจากแปลงไฟล์เสร็จสิ้น", "general.zip_geo": "บันทึกผลลัพธ์ข้อมูลเชิงพื้นที่ทั้งหมดเป็น ZIP", "general.zip_geo_hint": "Shapefile, GeoJSON, KML, GPX และผลลัพธ์เชิงพื้นที่อื่นๆ จะถูกรวมเป็นไฟล์ ZIP หนึ่งไฟล์ต่อหนึ่งรายการในทุกพรีเซ็ต\nหากปิด: จะทำเฉพาะพรีเซ็ตที่เลือก \"บันทึกผลลัพธ์เป็น ZIP\"", + "general.extension_change": "เปลี่ยนชื่อนามสกุลไฟล์", + "general.extension_change_hint": "แปลงไฟล์เป็นรูปแบบใหม่โดยอัตโนมัติเมื่อคุณเปลี่ยนนามสกุลไฟล์ (เช่น .png เป็น .pdf)", + "general.convert_on_extension_change": "แปลงไฟล์เมื่อนามสกุลไฟล์เปลี่ยน", + "general.confirm_extension_change": "ถามเพื่อยืนยันก่อนแปลงไฟล์", + "general.confirm_extension_change_hint": "แสดงหน้าต่างยืนยันก่อนแปลงไฟล์ที่เปลี่ยนชื่อ", + "extension_confirm.title": "แปลงไฟล์หรือไม่?", + "extension_confirm.text": "นามสกุลไฟล์ของ \"{0}\" เปลี่ยนจาก {1} เป็น {2} คุณต้องการให้ Filee แปลงไฟล์เป็น {2} หรือไม่?", + "extension_confirm.convert": "แปลงไฟล์", + "extension_confirm.dont_ask": "แปลงไฟล์โดยตรงเสมอโดยไม่ต้องถาม", "general.check_updates": "ตรวจสอบการอัปเดตอัตโนมัติ", "general.close_action": "เมื่อปิดหน้าต่าง", "general.close.Ask": "ถามทุกครั้ง", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 88cf74306..b11549c10 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -431,6 +431,15 @@ "general.browser_drops_only_converted_hint": "Özgün dosya geçici klasörde bekler ve dönüştürülünce silinir.", "general.zip_geo": "Her coğrafi veri sonucunu ZIP olarak kaydet", "general.zip_geo_hint": "Tüm ön ayarlarda Shapefile, GeoJSON, KML, GPX ve diğer coğrafi veri sonuçları dosya başına tek bir ZIP'e paketlenir.\nKapalıysa yalnızca “Sonucu ZIP olarak kaydet” seçili ön ayarlar bunu yapar.", + "general.extension_change": "Dosya uzantısı yeniden adlandırma", + "general.extension_change_hint": "Uzantısını değiştirdiğinizde (.png yerine .pdf gibi) dosyayı otomatik olarak yeni biçimine dönüştürün.", + "general.convert_on_extension_change": "Uzantı değiştirildiğinde dosyaları dönüştür", + "general.confirm_extension_change": "Dönüştürmeden önce onay iste", + "general.confirm_extension_change_hint": "Yeniden adlandırılan dosyayı dönüştürmeden önce bir onay iletişim kutusu gösterin.", + "extension_confirm.title": "Dosya dönüştürülsün mü?", + "extension_confirm.text": "\"{0}\" dosyasının uzantısı {1} yerine {2} olarak değiştirildi. Filee'nin bu dosyayı {2} biçimine dönüştürmesini ister misiniz?", + "extension_confirm.convert": "Dönüştür", + "extension_confirm.dont_ask": "Her zaman sormadan doğrudan dönüştür", "general.check_updates": "Güncellemeleri otomatik denetle", "general.close_action": "Pencereyi kapattığımda", "general.close.Ask": "Her seferinde sor", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 9b8cefcdd..ac5844175 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -443,6 +443,15 @@ "general.browser_drops_only_converted_hint": "原文件先放在临时文件夹中,转换完成后删除。", "general.zip_geo": "将所有地理信息结果保存为 ZIP", "general.zip_geo_hint": "无论使用哪个预设,Shapefile、GeoJSON、KML、GPX 等地理信息结果都会按每个文件打包成一个 ZIP。\n关闭后,只有开启了“将结果保存为 ZIP”的预设会这样做。", + "general.extension_change": "重命名文件扩展名", + "general.extension_change_hint": "当您更改文件扩展名(如将 .png 改为 .pdf)时,自动将其转换为新格式。", + "general.convert_on_extension_change": "更改扩展名时自动转换文件", + "general.confirm_extension_change": "转换前提示确认", + "general.confirm_extension_change_hint": "在转换已重命名的文件前显示确认对话框。", + "extension_confirm.title": "转换文件?", + "extension_confirm.text": "“{0}”的文件扩展名已从 {1} 改为 {2}。是否让 Filee 将该文件转换为 {2}?", + "extension_confirm.convert": "转换", + "extension_confirm.dont_ask": "始终直接转换,不再询问", "general.check_updates": "自动检查更新", "general.close_action": "关闭窗口时", "general.close.Ask": "每次询问", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index aa55dc24b..33a0d5dda 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -443,6 +443,15 @@ "general.browser_drops_only_converted_hint": "原始檔案先放在臨時資料夾中,轉換完成後刪除。", "general.zip_geo": "將所有地理資訊結果儲存為 ZIP", "general.zip_geo_hint": "無論使用哪個預設,Shapefile、GeoJSON、KML、GPX 等地理資訊結果都會按每個檔案打包成一個 ZIP。\n關閉後,只有開啟了「將結果儲存為 ZIP」的預設會這樣做。", + "general.extension_change": "重新命名副檔名", + "general.extension_change_hint": "當您變更檔案副檔名(例如將 .png 改為 .pdf)時,自動將其轉換為新格式。", + "general.convert_on_extension_change": "變更副檔名時自動轉換檔案", + "general.confirm_extension_change": "轉換前詢問確認", + "general.confirm_extension_change_hint": "在轉換已重新命名的檔案前顯示確認對話方塊。", + "extension_confirm.title": "轉換檔案?", + "extension_confirm.text": "「{0}」的副檔名已從 {1} 變更為 {2}。是否讓 Filee 將該檔案轉換為 {2}?", + "extension_confirm.convert": "轉換", + "extension_confirm.dont_ask": "一律直接轉換,不再詢問", "general.check_updates": "自動檢查更新", "general.close_action": "關閉視窗時", "general.close.Ask": "每次詢問", diff --git a/tests/Filee.App.Tests/ExtensionChangeSettingsTests.cs b/tests/Filee.App.Tests/ExtensionChangeSettingsTests.cs index 36fb18c27..c5f154c25 100644 --- a/tests/Filee.App.Tests/ExtensionChangeSettingsTests.cs +++ b/tests/Filee.App.Tests/ExtensionChangeSettingsTests.cs @@ -12,7 +12,7 @@ public sealed class ExtensionChangeSettingsTests private sealed class MockLocalizer : ILocalizer { public string Language => "en"; - public event EventHandler? LanguageChanged; + public event EventHandler? LanguageChanged { add { } remove { } } public string this[string key] => key; public string Format(string key, params object[] args) => string.Format(key, args); } diff --git a/tests/Filee.Core.Tests/ExtensionChangeWatcherTests.cs b/tests/Filee.Core.Tests/ExtensionChangeWatcherTests.cs index 3bc512ce4..7457628fa 100644 --- a/tests/Filee.Core.Tests/ExtensionChangeWatcherTests.cs +++ b/tests/Filee.Core.Tests/ExtensionChangeWatcherTests.cs @@ -50,7 +50,7 @@ public void ShouldIgnorePath_filters_system_and_temp_files() public async Task HandleRename_ignores_same_extension() { var watcher = new ExtensionChangeWatcher(CreatePlanner, (_, _, _, _) => Task.FromResult(true)); - var candidate = await watcher.HandleRenameAsync(@"C:\folder\photo1.png", @"C:\folder\photo2.png"); + var candidate = await watcher.HandleRenameAsync(@"C:\folder\photo1.png", @"C:\folder\photo2.png", TestContext.Current.CancellationToken); Assert.Null(candidate); } @@ -58,7 +58,7 @@ public async Task HandleRename_ignores_same_extension() public async Task HandleRename_ignores_unsupported_target_format() { var watcher = new ExtensionChangeWatcher(CreatePlanner, (_, _, _, _) => Task.FromResult(true)); - var candidate = await watcher.HandleRenameAsync(@"C:\folder\photo.png", @"C:\folder\photo.xyzrandom"); + var candidate = await watcher.HandleRenameAsync(@"C:\folder\photo.png", @"C:\folder\photo.xyzrandom", TestContext.Current.CancellationToken); Assert.Null(candidate); } @@ -67,7 +67,7 @@ public async Task HandleRename_ignores_when_no_conversion_route_exists() { // DummyConverter has no route from png to mp4 var watcher = new ExtensionChangeWatcher(CreatePlanner, (_, _, _, _) => Task.FromResult(true)); - var candidate = await watcher.HandleRenameAsync(@"C:\folder\photo.png", @"C:\folder\photo.mp4"); + var candidate = await watcher.HandleRenameAsync(@"C:\folder\photo.png", @"C:\folder\photo.mp4", TestContext.Current.CancellationToken); Assert.Null(candidate); } @@ -79,7 +79,7 @@ public async Task HandleRename_ignores_paths_currently_in_progress() watcher.MarkInProgress(path); Assert.True(watcher.IsInProgress(path)); - var candidate = await watcher.HandleRenameAsync(@"C:\folder\busy.png", path); + var candidate = await watcher.HandleRenameAsync(@"C:\folder\busy.png", path, TestContext.Current.CancellationToken); Assert.Null(candidate); watcher.UnmarkInProgress(path); @@ -97,10 +97,10 @@ public async Task HandleRename_ignores_file_if_already_matching_target_signature var newPath = Path.Combine(dir, "doc.pdf"); // Write PDF bytes even though old name was png - await File.WriteAllBytesAsync(newPath, PdfHeader); + await File.WriteAllBytesAsync(newPath, PdfHeader, TestContext.Current.CancellationToken); var watcher = new ExtensionChangeWatcher(CreatePlanner, (_, _, _, _) => Task.FromResult(true), settleTimeout: TimeSpan.FromSeconds(1)); - var candidate = await watcher.HandleRenameAsync(oldPath, newPath); + var candidate = await watcher.HandleRenameAsync(oldPath, newPath, TestContext.Current.CancellationToken); // Sniff shows it is already a PDF internally, so it should NOT convert! Assert.Null(candidate); @@ -122,7 +122,7 @@ public async Task HandleRename_detects_valid_candidate_when_signature_is_png_and var newPath = Path.Combine(dir, "doc.pdf"); // Write PNG bytes (the user renamed doc.png to doc.pdf, so doc.pdf has PNG data inside) - await File.WriteAllBytesAsync(newPath, PngHeader); + await File.WriteAllBytesAsync(newPath, PngHeader, TestContext.Current.CancellationToken); var detected = false; var watcher = new ExtensionChangeWatcher( @@ -139,7 +139,7 @@ public async Task HandleRename_detects_valid_candidate_when_signature_is_png_and return Task.CompletedTask; }; - var candidate = await watcher.HandleRenameAsync(oldPath, newPath); + var candidate = await watcher.HandleRenameAsync(oldPath, newPath, TestContext.Current.CancellationToken); Assert.NotNull(candidate); Assert.True(detected); Assert.Equal("png", candidate.SourceFormat.Id); @@ -159,25 +159,25 @@ public async Task ConvertSafely_converts_and_atomically_replaces_target_file() try { var filePath = Path.Combine(dir, "photo.pdf"); - await File.WriteAllBytesAsync(filePath, PngHeader); + await File.WriteAllBytesAsync(filePath, PngHeader, TestContext.Current.CancellationToken); var watcher = new ExtensionChangeWatcher( CreatePlanner, - async (_, _, output, _) => + async (_, _, output, ct) => { // Mock conversion producing valid PDF - await File.WriteAllBytesAsync(output, PdfHeader); + await File.WriteAllBytesAsync(output, PdfHeader, ct); return true; }); var pngFormat = FormatRegistry.Get("png"); var pdfFormat = FormatRegistry.Get("pdf"); - var success = await watcher.ConvertSafelyAsync(filePath, pngFormat, pdfFormat); + var success = await watcher.ConvertSafelyAsync(filePath, pngFormat, pdfFormat, TestContext.Current.CancellationToken); Assert.True(success); // Verify file was replaced with PDF content - var finalBytes = await File.ReadAllBytesAsync(filePath); + var finalBytes = await File.ReadAllBytesAsync(filePath, TestContext.Current.CancellationToken); Assert.Equal(PdfHeader, finalBytes); } finally @@ -194,7 +194,7 @@ public async Task ConvertSafely_preserves_original_file_if_conversion_fails() try { var filePath = Path.Combine(dir, "photo.pdf"); - await File.WriteAllBytesAsync(filePath, PngHeader); + await File.WriteAllBytesAsync(filePath, PngHeader, TestContext.Current.CancellationToken); var watcher = new ExtensionChangeWatcher( CreatePlanner, @@ -203,11 +203,11 @@ public async Task ConvertSafely_preserves_original_file_if_conversion_fails() var pngFormat = FormatRegistry.Get("png"); var pdfFormat = FormatRegistry.Get("pdf"); - var success = await watcher.ConvertSafelyAsync(filePath, pngFormat, pdfFormat); + var success = await watcher.ConvertSafelyAsync(filePath, pngFormat, pdfFormat, TestContext.Current.CancellationToken); Assert.False(success); // Verify file was preserved with original PNG bytes - var finalBytes = await File.ReadAllBytesAsync(filePath); + var finalBytes = await File.ReadAllBytesAsync(filePath, TestContext.Current.CancellationToken); Assert.Equal(PngHeader, finalBytes); } finally From 08231be2700fe666d877724167bc89d201ae7feb Mon Sep 17 00:00:00 2001 From: gtxPrime Date: Mon, 26 Oct 2026 17:57:30 +0530 Subject: [PATCH 3/8] feat: show Material You progress dialog during extension conversion and style confirmation dialog --- src/Filee.App/Assets/i18n/ar.json | 2 + src/Filee.App/Assets/i18n/de.json | 2 + src/Filee.App/Assets/i18n/en.json | 2 + src/Filee.App/Assets/i18n/es.json | 2 + src/Filee.App/Assets/i18n/fr.json | 2 + src/Filee.App/Assets/i18n/hi.json | 2 + src/Filee.App/Assets/i18n/ja.json | 2 + src/Filee.App/Assets/i18n/ko.json | 2 + src/Filee.App/Assets/i18n/ne.json | 2 + src/Filee.App/Assets/i18n/pl.json | 2 + src/Filee.App/Assets/i18n/pt-BR.json | 2 + src/Filee.App/Assets/i18n/pt-PT.json | 2 + src/Filee.App/Assets/i18n/ru.json | 2 + src/Filee.App/Assets/i18n/th.json | 2 + src/Filee.App/Assets/i18n/tr.json | 2 + src/Filee.App/Assets/i18n/zh-CN.json | 2 + src/Filee.App/Assets/i18n/zh-TW.json | 2 + .../Services/ExtensionChangeService.cs | 20 +++++++++- src/Filee.App/Services/WindowService.cs | 24 ++++++++++++ .../Views/ExtensionChangeConfirmWindow.axaml | 39 ++++++++++++------- .../ExtensionChangeConfirmWindow.axaml.cs | 7 ++++ .../Views/ExtensionChangeProgressWindow.axaml | 30 ++++++++++++++ .../ExtensionChangeProgressWindow.axaml.cs | 25 ++++++++++++ 23 files changed, 164 insertions(+), 15 deletions(-) create mode 100644 src/Filee.App/Views/ExtensionChangeProgressWindow.axaml create mode 100644 src/Filee.App/Views/ExtensionChangeProgressWindow.axaml.cs diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 0c009b065..d75590c26 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -440,6 +440,8 @@ "extension_confirm.text": "تم تغيير امتداد الملف \"{0}\" من {1} إلى {2}. هل ترغب في أن يقوم Filee بتحويل الملف إلى {2}؟", "extension_confirm.convert": "تحويل", "extension_confirm.dont_ask": "التحويل دائمًا مباشرة دون السؤال مجددًا", + "extension_progress.title": "جارٍ تحويل الملف...", + "extension_progress.text": "جارٍ تحويل \"{0}\" إلى {1}...", "general.check_updates": "التحقق من التحديثات تلقائيًا", "general.close_action": "عند إغلاق النافذة", "general.close.Ask": "السؤال كل مرة", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index 6b55f0289..35a8bfb46 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -440,6 +440,8 @@ "extension_confirm.text": "Die Dateiendung von „{0}“ wurde von {1} in {2} geändert. Möchten Sie, dass Filee die Datei in {2} konvertiert?", "extension_confirm.convert": "Konvertieren", "extension_confirm.dont_ask": "Immer direkt konvertieren, ohne nachzufragen", + "extension_progress.title": "Datei wird konvertiert …", + "extension_progress.text": "„{0}“ wird in {1} konvertiert …", "general.check_updates": "Automatisch nach Updates suchen", "general.close_action": "Beim Schließen des Fensters", "general.close.Ask": "Jedes Mal fragen", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index ca91fe2fe..58c10c554 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -452,6 +452,8 @@ "extension_confirm.text": "The file extension of \"{0}\" was changed from {1} to {2}. Would you like Filee to convert the file to {2}?", "extension_confirm.convert": "Convert", "extension_confirm.dont_ask": "Always convert directly without asking", + "extension_progress.title": "Converting file...", + "extension_progress.text": "Converting \"{0}\" to {1}...", "general.check_updates": "Check for updates automatically", "general.close_action": "When I close the window", "general.close.Ask": "Ask every time", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 98baafb07..84e3cdaca 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -440,6 +440,8 @@ "extension_confirm.text": "La extensión del archivo \"{0}\" se cambió de {1} a {2}. ¿Deseas que Filee convierta el archivo a {2}?", "extension_confirm.convert": "Convertir", "extension_confirm.dont_ask": "Convertir siempre directamente sin preguntar", + "extension_progress.title": "Convirtiendo archivo...", + "extension_progress.text": "Convirtiendo \"{0}\" a {1}...", "general.check_updates": "Buscar actualizaciones automáticamente", "general.close_action": "Al cerrar la ventana", "general.close.Ask": "Preguntar cada vez", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index a9df7e7d1..0e023478c 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -452,6 +452,8 @@ "extension_confirm.text": "L'extension du fichier « {0} » a été modifiée de {1} en {2}. Souhaitez-vous que Filee convertisse le fichier en {2} ?", "extension_confirm.convert": "Convertir", "extension_confirm.dont_ask": "Toujours convertir directement sans demander", + "extension_progress.title": "Conversion du fichier en cours...", + "extension_progress.text": "Conversion de « {0} » en {1}...", "general.check_updates": "Rechercher automatiquement les mises à jour", "general.close_action": "À la fermeture de la fenêtre", "general.close.Ask": "Toujours demander", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 30608260c..27a05e521 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -440,6 +440,8 @@ "extension_confirm.text": "\"{0}\" का फ़ाइल एक्सटेंशन {1} से बदलकर {2} कर दिया गया है। क्या आप चाहते हैं कि Filee फ़ाइल को {2} में बदल दे?", "extension_confirm.convert": "बदलें", "extension_confirm.dont_ask": "बिना पूछे हमेशा सीधे बदलें", + "extension_progress.title": "फ़ाइल बदली जा रही है...", + "extension_progress.text": "\"{0}\" को {1} में बदला जा रहा है...", "general.check_updates": "अपडेट अपने-आप जाँचें", "general.close_action": "जब मैं विंडो बंद करूँ", "general.close.Ask": "हर बार पूछें", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 18c47f31a..cf7fea57a 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -440,6 +440,8 @@ "extension_confirm.text": "「{0}」の拡張子が {1} から {2} に変更されました。Filee でこのファイルを {2} に変換しますか?", "extension_confirm.convert": "変換", "extension_confirm.dont_ask": "確認せずに常に直接変換する", + "extension_progress.title": "ファイルを変換中…", + "extension_progress.text": "「{0}」を {1} に変換しています…", "general.check_updates": "更新を自動で確認", "general.close_action": "ウィンドウを閉じたとき", "general.close.Ask": "毎回確認", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 832803164..cdfded454 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -452,6 +452,8 @@ "extension_confirm.text": "\"{0}\"의 파일 확장자가 {1}에서 {2}(으)로 변경되었습니다. Filee에서 파일을 {2}(으)로 변환할까요?", "extension_confirm.convert": "변환", "extension_confirm.dont_ask": "묻지 않고 항상 직접 변환", + "extension_progress.title": "파일 변환 중…", + "extension_progress.text": "\"{0}\"을(를) {1}(으)로 변환하는 중…", "general.check_updates": "자동으로 업데이트 확인", "general.close_action": "창을 닫을 때", "general.close.Ask": "매번 묻기", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 95257c177..2c05c49c9 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -440,6 +440,8 @@ "extension_confirm.text": "\"{0}\" को फाइल विस्तार {1} बाट {2} मा परिवर्तन गरिएको थियो। के तपाईं Filee ले फाइललाई {2} मा रूपान्तरण गरोस् भन्ने चाहनुहुन्छ?", "extension_confirm.convert": "रूपान्तरण गर्नुहोस्", "extension_confirm.dont_ask": "सोधपुछ नगरी सधैं सिधै रूपान्तरण गर्नुहोस्", + "extension_progress.title": "फाइल रूपान्तरण हुँदैछ...", + "extension_progress.text": "\"{0}\" लाई {1} मा रूपान्तरण गरिँदैछ...", "general.check_updates": "स्वचालित रूपमा अपडेटहरू जाँच गर्नुहोस्", "general.close_action": "जब म विन्डो बन्द गर्छु", "general.close.Ask": "प्रत्येक पटक सोध्नुहोस्", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 3f97c2bc8..f80de8cf3 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -452,6 +452,8 @@ "extension_confirm.text": "Rozszerzenie pliku „{0}” zostało zmienione z {1} na {2}. Czy chcesz, aby Filee przekonwertowało ten plik na {2}?", "extension_confirm.convert": "Konwertuj", "extension_confirm.dont_ask": "Zawsze konwertuj bezpośrednio bez pytania", + "extension_progress.title": "Konwertowanie pliku...", + "extension_progress.text": "Konwertowanie „{0}” na {1}...", "general.check_updates": "Automatycznie sprawdzaj aktualizacje", "general.close_action": "Po zamknięciu okna", "general.close.Ask": "Pytaj za każdym razem", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 1eeef081f..c8278c061 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -452,6 +452,8 @@ "extension_confirm.text": "A extensão de \"{0}\" foi alterada de {1} para {2}. Deseja que o Filee converta o arquivo para {2}?", "extension_confirm.convert": "Converter", "extension_confirm.dont_ask": "Sempre converter diretamente sem perguntar", + "extension_progress.title": "Convertendo arquivo...", + "extension_progress.text": "Convertendo \"{0}\" para {1}...", "general.check_updates": "Procurar atualizações automaticamente", "general.close_action": "Quando eu fechar a janela", "general.close.Ask": "Perguntar sempre", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index 35bf140b6..eabcf31db 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -452,6 +452,8 @@ "extension_confirm.text": "A extensão do ficheiro \"{0}\" foi alterada de {1} para {2}. Deseja que o Filee converta o ficheiro para {2}?", "extension_confirm.convert": "Converter", "extension_confirm.dont_ask": "Converter sempre diretamente sem perguntar", + "extension_progress.title": "A converter ficheiro...", + "extension_progress.text": "A converter \"{0}\" para {1}...", "general.check_updates": "Procurar atualizações automaticamente", "general.close_action": "Ao fechar a janela", "general.close.Ask": "Perguntar sempre", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 6653d90ec..e8fad659b 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -452,6 +452,8 @@ "extension_confirm.text": "Расширение файла «{0}» было изменено с {1} на {2}. Вы хотите, чтобы Filee конвертировал файл в {2}?", "extension_confirm.convert": "Конвертировать", "extension_confirm.dont_ask": "Всегда конвертировать напрямую без запроса", + "extension_progress.title": "Конвертация файла…", + "extension_progress.text": "Конвертация «{0}» в {1}…", "general.check_updates": "Проверять обновления автоматически", "general.close_action": "Когда я закрываю окно", "general.close.Ask": "Спрашивать каждый раз", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 17a40d905..6f644b428 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -440,6 +440,8 @@ "extension_confirm.text": "นามสกุลไฟล์ของ \"{0}\" เปลี่ยนจาก {1} เป็น {2} คุณต้องการให้ Filee แปลงไฟล์เป็น {2} หรือไม่?", "extension_confirm.convert": "แปลงไฟล์", "extension_confirm.dont_ask": "แปลงไฟล์โดยตรงเสมอโดยไม่ต้องถาม", + "extension_progress.title": "กำลังแปลงไฟล์...", + "extension_progress.text": "กำลังแปลง \"{0}\" เป็น {1}...", "general.check_updates": "ตรวจสอบการอัปเดตอัตโนมัติ", "general.close_action": "เมื่อปิดหน้าต่าง", "general.close.Ask": "ถามทุกครั้ง", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index b11549c10..36f03d1dd 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -440,6 +440,8 @@ "extension_confirm.text": "\"{0}\" dosyasının uzantısı {1} yerine {2} olarak değiştirildi. Filee'nin bu dosyayı {2} biçimine dönüştürmesini ister misiniz?", "extension_confirm.convert": "Dönüştür", "extension_confirm.dont_ask": "Her zaman sormadan doğrudan dönüştür", + "extension_progress.title": "Dosya dönüştürülüyor...", + "extension_progress.text": "\"{0}\" dosyası {1} biçimine dönüştürülüyor...", "general.check_updates": "Güncellemeleri otomatik denetle", "general.close_action": "Pencereyi kapattığımda", "general.close.Ask": "Her seferinde sor", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index ac5844175..50de1887e 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -452,6 +452,8 @@ "extension_confirm.text": "“{0}”的文件扩展名已从 {1} 改为 {2}。是否让 Filee 将该文件转换为 {2}?", "extension_confirm.convert": "转换", "extension_confirm.dont_ask": "始终直接转换,不再询问", + "extension_progress.title": "正在转换文件…", + "extension_progress.text": "正在将“{0}”转换为 {1}…", "general.check_updates": "自动检查更新", "general.close_action": "关闭窗口时", "general.close.Ask": "每次询问", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 33a0d5dda..6c8f70358 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -452,6 +452,8 @@ "extension_confirm.text": "「{0}」的副檔名已從 {1} 變更為 {2}。是否讓 Filee 將該檔案轉換為 {2}?", "extension_confirm.convert": "轉換", "extension_confirm.dont_ask": "一律直接轉換,不再詢問", + "extension_progress.title": "正在轉換檔案…", + "extension_progress.text": "正在將「{0}」轉換為 {1}…", "general.check_updates": "自動檢查更新", "general.close_action": "關閉視窗時", "general.close.Ask": "每次詢問", diff --git a/src/Filee.App/Services/ExtensionChangeService.cs b/src/Filee.App/Services/ExtensionChangeService.cs index 505f1634c..cd072b2ca 100644 --- a/src/Filee.App/Services/ExtensionChangeService.cs +++ b/src/Filee.App/Services/ExtensionChangeService.cs @@ -148,7 +148,25 @@ private async Task OnExtensionChangeDetectedAsync(ExtensionChangeCandidate candi _log.LogInformation("Executing extension conversion for {File} from {Source} to {Target}", candidate.NewPath, candidate.SourceFormat.Id, candidate.TargetFormat.Id); - await candidate.PerformConversionAsync(CancellationToken.None).ConfigureAwait(false); + IDisposable? progress = null; + try + { + progress = await Dispatcher.UIThread.InvokeAsync(() => + { + return _windowService.ShowExtensionChangeProgress( + candidate.FileName, + candidate.TargetFormat.DisplayName); + }); + + await candidate.PerformConversionAsync(CancellationToken.None).ConfigureAwait(false); + } + finally + { + if (progress is not null) + { + await Dispatcher.UIThread.InvokeAsync(() => progress.Dispose()); + } + } } public async ValueTask DisposeAsync() diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs index d448f799a..d666c4d43 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -6,6 +6,7 @@ using Avalonia.Layout; using Avalonia.Media; using Avalonia.Platform.Storage; +using Avalonia.Threading; using Filee.App.ViewModels; using Filee.App.Views; using Filee.Core.Conversion; @@ -140,6 +141,29 @@ public async Task ConfirmExtensionChangeAsync(string fileName, string sour return await closed.Task; } + /// Shows a progress dialog while converting a file whose extension was changed, and returns a disposable handle to close it. + public IDisposable ShowExtensionChangeProgress(string fileName, string targetFormat) + { + var message = loc.Format("extension_progress.text", fileName, targetFormat); + var dialog = new ExtensionChangeProgressWindow(message); + PrepareWindow(dialog); + dialog.Show(); + return new ExtensionChangeProgressScope(dialog); + } + + private sealed class ExtensionChangeProgressScope(Window window) : IDisposable + { + private int _disposed; + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + { + Dispatcher.UIThread.Post(() => window.Close()); + } + } + } + public MainWindow? Main => _main; /// diff --git a/src/Filee.App/Views/ExtensionChangeConfirmWindow.axaml b/src/Filee.App/Views/ExtensionChangeConfirmWindow.axaml index feb2adc4b..f772c1e80 100644 --- a/src/Filee.App/Views/ExtensionChangeConfirmWindow.axaml +++ b/src/Filee.App/Views/ExtensionChangeConfirmWindow.axaml @@ -7,19 +7,30 @@ SizeToContent="Height" CanResize="False" WindowStartupLocation="CenterScreen" + WindowDecorations="None" + Background="Transparent" + TransparencyLevelHint="Transparent" Topmost="True" - ShowInTaskbar="True"> - - - - - - - -