From 886d5355f3039a8efc8c348e2b5a005910808793 Mon Sep 17 00:00:00 2001 From: Jason Barden Date: Wed, 1 Apr 2026 21:04:59 +0100 Subject: [PATCH 1/2] feat: add AppSettings model and database migration for application settings --- .../Data/FileAppDbContext.cs | 7 ++ .../20260401200334_AddAppSettings.Designer.cs | 98 ++++++++++++++++++ .../20260401200334_AddAppSettings.cs | 41 ++++++++ .../FileAppDbContextModelSnapshot.cs | 22 ++++ src/AStar.Dev.File.App/Models/AppSetting.cs | 10 ++ .../ViewModels/MainWindowViewModel.cs | 74 ++++++++++--- src/AStar.Dev.File.App/Views/MainWindow.axaml | 38 ++++--- src/AStar.Dev.File.App/design-time.db | Bin 0 -> 40960 bytes 8 files changed, 257 insertions(+), 33 deletions(-) create mode 100644 src/AStar.Dev.File.App/Migrations/20260401200334_AddAppSettings.Designer.cs create mode 100644 src/AStar.Dev.File.App/Migrations/20260401200334_AddAppSettings.cs create mode 100644 src/AStar.Dev.File.App/Models/AppSetting.cs create mode 100644 src/AStar.Dev.File.App/design-time.db diff --git a/src/AStar.Dev.File.App/Data/FileAppDbContext.cs b/src/AStar.Dev.File.App/Data/FileAppDbContext.cs index f76de70..559dd95 100644 --- a/src/AStar.Dev.File.App/Data/FileAppDbContext.cs +++ b/src/AStar.Dev.File.App/Data/FileAppDbContext.cs @@ -6,6 +6,7 @@ namespace AStar.Dev.File.App.Data; public class FileAppDbContext(DbContextOptions options) : DbContext(options) { public DbSet ScannedFiles => Set(); + public DbSet AppSettings => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -19,5 +20,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(e => e.FileType) .HasConversion(); }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.Key).IsUnique(); + }); } } diff --git a/src/AStar.Dev.File.App/Migrations/20260401200334_AddAppSettings.Designer.cs b/src/AStar.Dev.File.App/Migrations/20260401200334_AddAppSettings.Designer.cs new file mode 100644 index 0000000..f02ecdb --- /dev/null +++ b/src/AStar.Dev.File.App/Migrations/20260401200334_AddAppSettings.Designer.cs @@ -0,0 +1,98 @@ +// +using System; +using AStar.Dev.File.App.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace AStar.Dev.File.App.Migrations +{ + [DbContext(typeof(FileAppDbContext))] + [Migration("20260401200334_AddAppSettings")] + partial class AddAppSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.4"); + + modelBuilder.Entity("AStar.Dev.File.App.Models.AppSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("AStar.Dev.File.App.Models.ScannedFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FileType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FullPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScannedAt") + .HasColumnType("TEXT"); + + b.Property("LastViewed") + .HasColumnType("TEXT"); + + b.Property("PendingDelete") + .HasColumnType("INTEGER"); + + b.Property("RootPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SizeInBytes") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("FullPath") + .IsUnique(); + + b.HasIndex("SizeInBytes"); + + b.ToTable("ScannedFiles"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/AStar.Dev.File.App/Migrations/20260401200334_AddAppSettings.cs b/src/AStar.Dev.File.App/Migrations/20260401200334_AddAppSettings.cs new file mode 100644 index 0000000..f4d61f1 --- /dev/null +++ b/src/AStar.Dev.File.App/Migrations/20260401200334_AddAppSettings.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AStar.Dev.File.App.Migrations +{ + /// + public partial class AddAppSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AppSettings", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Key = table.Column(type: "TEXT", nullable: false), + Value = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppSettings", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_AppSettings_Key", + table: "AppSettings", + column: "Key", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AppSettings"); + } + } +} diff --git a/src/AStar.Dev.File.App/Migrations/FileAppDbContextModelSnapshot.cs b/src/AStar.Dev.File.App/Migrations/FileAppDbContextModelSnapshot.cs index 122b617..3caedfe 100644 --- a/src/AStar.Dev.File.App/Migrations/FileAppDbContextModelSnapshot.cs +++ b/src/AStar.Dev.File.App/Migrations/FileAppDbContextModelSnapshot.cs @@ -17,6 +17,28 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "10.0.4"); + modelBuilder.Entity("AStar.Dev.File.App.Models.AppSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("AppSettings"); + }); + modelBuilder.Entity("AStar.Dev.File.App.Models.ScannedFile", b => { b.Property("Id") diff --git a/src/AStar.Dev.File.App/Models/AppSetting.cs b/src/AStar.Dev.File.App/Models/AppSetting.cs new file mode 100644 index 0000000..e4aa3ae --- /dev/null +++ b/src/AStar.Dev.File.App/Models/AppSetting.cs @@ -0,0 +1,10 @@ +namespace AStar.Dev.File.App.Models; + +public class AppSetting +{ + public int Id { get; set; } + + public required string Key { get; set; } + + public required string Value { get; set; } +} diff --git a/src/AStar.Dev.File.App/ViewModels/MainWindowViewModel.cs b/src/AStar.Dev.File.App/ViewModels/MainWindowViewModel.cs index ea43c5d..4bd8460 100644 --- a/src/AStar.Dev.File.App/ViewModels/MainWindowViewModel.cs +++ b/src/AStar.Dev.File.App/ViewModels/MainWindowViewModel.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -107,6 +108,28 @@ public MainWindowViewModel( _fileScannerService = fileScannerService; _folderPickerService = folderPickerService; _dbContextFactory = dbContextFactory; + _ = InitializeAsync(); + } + + private async Task InitializeAsync() + { + await LoadSelectedFolderPathAsync(); + } + + private async Task LoadSelectedFolderPathAsync() + { + await using var db = await _dbContextFactory.CreateDbContextAsync(); + var setting = await db.AppSettings.FirstOrDefaultAsync(s => s.Key == "SelectedFolderPath"); + + if (setting is not null && !string.IsNullOrWhiteSpace(setting.Value) && Directory.Exists(setting.Value)) + { + SelectedFolderPath = setting.Value; + } + else + { + var defaultPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)); + SelectedFolderPath = defaultPath; + } } [RelayCommand(CanExecute = nameof(CanSelectFolder))] @@ -114,7 +137,27 @@ private async Task SelectFolder() { var path = await _folderPickerService.OpenFolderPickerAsync(); if (!string.IsNullOrEmpty(path)) + { SelectedFolderPath = path; + await SaveSelectedFolderPathAsync(path); + } + } + + private async Task SaveSelectedFolderPathAsync(string path) + { + await using var db = await _dbContextFactory.CreateDbContextAsync(); + var setting = await db.AppSettings.FirstOrDefaultAsync(s => s.Key == "SelectedFolderPath"); + + if (setting is not null) + { + setting.Value = path; + } + else + { + db.AppSettings.Add(new Models.AppSetting { Key = "SelectedFolderPath", Value = path }); + } + + await db.SaveChangesAsync(); } private bool CanSelectFolder() => !IsScanning; @@ -246,12 +289,9 @@ private async Task LoadScannedFilesAsync() { try { - if (string.IsNullOrWhiteSpace(SelectedFolderPath)) - return; + if (string.IsNullOrWhiteSpace(SelectedFolderPath)) return; - var prefix = SelectedFolderPath.TrimEnd(System.IO.Path.DirectorySeparatorChar, - System.IO.Path.AltDirectorySeparatorChar) - + System.IO.Path.DirectorySeparatorChar; + var prefix = SelectedFolderPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; await using var db = await _dbContextFactory.CreateDbContextAsync(); @@ -260,13 +300,11 @@ private async Task LoadScannedFilesAsync() IQueryable query; if (ShowDuplicatesOnly) { - // Use subquery to find and filter for duplicate sizes in a single query - // This avoids materializing a potentially huge list of sizes var duplicateSizeSubquery = baseQuery .GroupBy(f => f.SizeInBytes) .Where(g => g.Count() > 1) .Select(g => g.Key); - + query = baseQuery .Where(f => duplicateSizeSubquery.Contains(f.SizeInBytes)) .OrderBy(f => f.SizeInBytes) @@ -282,13 +320,7 @@ private async Task LoadScannedFilesAsync() TotalFileCount = await query.CountAsync(); - // Clamp current page if the page count shrank (e.g. after a page-size increase) - if (CurrentPage > TotalPages) - { - _suppressPageReload = true; - CurrentPage = TotalPages; - _suppressPageReload = false; - } + ClampCurrentPage(); var files = await query .Skip((CurrentPage - 1) * PageSize) @@ -296,12 +328,20 @@ private async Task LoadScannedFilesAsync() .ToListAsync(); ScannedFiles.Clear(); - foreach (var file in files) - ScannedFiles.Add(new ScannedFileDisplayItem(file)); + files.ForEach(file => ScannedFiles.Add(new ScannedFileDisplayItem(file))); } catch (Exception ex) { StatusMessages.Add($"Error loading files: {ex.Message}"); } } + + private void ClampCurrentPage() + { + if (CurrentPage <= TotalPages) return; + + _suppressPageReload = true; + CurrentPage = TotalPages; + _suppressPageReload = false; + } } diff --git a/src/AStar.Dev.File.App/Views/MainWindow.axaml b/src/AStar.Dev.File.App/Views/MainWindow.axaml index a68bb26..ef0dcc4 100644 --- a/src/AStar.Dev.File.App/Views/MainWindow.axaml +++ b/src/AStar.Dev.File.App/Views/MainWindow.axaml @@ -128,21 +128,27 @@ - - - - - - + @@ -165,7 +171,7 @@ Width="180" /> + Width="180" /> diff --git a/src/AStar.Dev.File.App/design-time.db b/src/AStar.Dev.File.App/design-time.db new file mode 100644 index 0000000000000000000000000000000000000000..82b7b9ce839efc9f70537ea10945130fe6900846 GIT binary patch literal 40960 zcmeI)Z*SW~90zba-kK((^LP~P5r<30qW+PcWGhS{k#%mdn&z)lwGWVG>f5lE+Fk4j z$^)bnw70;U*c;%bz$1`&!n@#{INil{vOOUZ)z?zwKX>=LyU*QOb!w&CuDc;6&;7xf z9g>Q0Psn71uLuzYA;Ug-_L**3c5rKY!uBF}Jnt|gY`-q8$yFhjdn3qS-2L$GKOa24 z^YU(sqgC@(rB8b(W}?VZnM!fJ2iHlD6Ip&9|~!8jKf;zgdFH6 zq}DT=MuSyys5i{bNx-Oe#MF;js5o4laAfy~G?A1qjf8Bruv-*I#i?5N)b>2;?74j! zSY7u=YIsjCLK^VbSL2Soh2vBCQWHTwH6rA~Dy5%RvV|{M3qDP4!QQanZ`t8@e2DD4 z;o~z|(~{{;xJoUxZm$$JHpG+hKnAb+><@k`ps$A1JEe)!m6-ueBtG=)GrGx2mVQ3I zl`RyD;_F}>lW!G&e0wIE!Y47L_n#lL1>ZG6I=&x9poysv&yqdA@6f@5bT%y-te}K$c z_G!q9CzoBED~b?{*1|+H*?Q?^vW1-;@zNL%t!3$Zhwh7k9lE|3eC-CIKe$M}D9sLb zYM!f6zX}=M9%i7D_l7xfxJfYW{J?jHr{NJD1gxovsQHp1trWi55ry#@j3>4|4E^!B zm0F9XrCyGnfmfs3^r$AX8?q)|ZlzGw{nMAT#iJ{kMMzNwCbahc3*&kPGxyKUIF#w0 zQMOXg&J!wY`O;d{k$Zw5{}CNvg8&2|009U<00Izz00bZa0SG|g|0VE=C<)E7T7JBx zR<~4*l+`C{^@&>9E`MIGsE?`}D!ZUY&;OGAiy;3lzhOIU5P$##AOHafKmY;|fB*y_ z009X6M*mwrf#d-)s2kQp5&`kG*wlr<{300Izz00bZa0SG_<0uZ>l0{H*`%^h3x3<3~<00bZa0SG_<0uX=z1RyXK_!m3t BMtlGO literal 0 HcmV?d00001 From a9da389563a58debab339bbfbb93de52189e3251 Mon Sep 17 00:00:00 2001 From: Jason Barden Date: Wed, 1 Apr 2026 21:20:02 +0100 Subject: [PATCH 2/2] feat: implement file deletion functionality with UI for pending deletions --- src/AStar.Dev.File.App/App.axaml.cs | 6 + .../Services/FileDeleteService.cs | 205 ++++++++++++++++++ .../ViewModels/DeletePendingViewModel.cs | 136 ++++++++++++ .../ViewModels/MainWindowViewModel.cs | 7 + .../Views/DeletePendingWindow.axaml | 127 +++++++++++ .../Views/DeletePendingWindow.axaml.cs | 11 + src/AStar.Dev.File.App/Views/MainWindow.axaml | 2 + .../Views/MainWindow.axaml.cs | 13 ++ 8 files changed, 507 insertions(+) create mode 100644 src/AStar.Dev.File.App/Services/FileDeleteService.cs create mode 100644 src/AStar.Dev.File.App/ViewModels/DeletePendingViewModel.cs create mode 100644 src/AStar.Dev.File.App/Views/DeletePendingWindow.axaml create mode 100644 src/AStar.Dev.File.App/Views/DeletePendingWindow.axaml.cs diff --git a/src/AStar.Dev.File.App/App.axaml.cs b/src/AStar.Dev.File.App/App.axaml.cs index 435b97f..44e4649 100644 --- a/src/AStar.Dev.File.App/App.axaml.cs +++ b/src/AStar.Dev.File.App/App.axaml.cs @@ -16,6 +16,10 @@ public partial class App : Application { private IServiceProvider? _services; + public IServiceProvider? Services => _services; + + public T? GetService() where T : class => _services?.GetService(typeof(T)) as T; + public override void Initialize() { AvaloniaXamlLoader.Load(this); @@ -56,8 +60,10 @@ private static IServiceProvider BuildServices() services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); return services.BuildServiceProvider(); } diff --git a/src/AStar.Dev.File.App/Services/FileDeleteService.cs b/src/AStar.Dev.File.App/Services/FileDeleteService.cs new file mode 100644 index 0000000..a035a19 --- /dev/null +++ b/src/AStar.Dev.File.App/Services/FileDeleteService.cs @@ -0,0 +1,205 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading.Tasks; + +namespace AStar.Dev.File.App.Services; + +public interface IFileDeleteService +{ + Task DeleteFileAsync(string filePath, bool moveToRecycleBin = true); + Task DeleteFilesAsync(IEnumerable filePaths, bool moveToRecycleBin = true); +} + +public class FileDeleteService : IFileDeleteService +{ + public async Task DeleteFileAsync(string filePath, bool moveToRecycleBin = true) + { + if (string.IsNullOrWhiteSpace(filePath) || !System.IO.File.Exists(filePath)) + return; + + await DeleteFilesAsync([filePath], moveToRecycleBin); + } + + public async Task DeleteFilesAsync(IEnumerable filePaths, bool moveToRecycleBin = true) + { + var files = filePaths.Where(f => System.IO.File.Exists(f)).ToList(); + if (files.Count == 0) + return; + + await Task.Run(() => + { + if (moveToRecycleBin) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + MoveFilesToRecycleBinWindows(files); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + MoveFilesToTrashLinux(files); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + MoveFilesToTrashMacOS(files); + } + else + { + PermanentlyDeleteFiles(files); + } + } + else + { + PermanentlyDeleteFiles(files); + } + }); + } + + private void PermanentlyDeleteFiles(IEnumerable filePaths) + { + foreach (var file in filePaths) + { + try + { + System.IO.File.Delete(file); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to delete {file}: {ex.Message}"); + } + } + } + + private void MoveFilesToTrashLinux(IEnumerable filePaths) + { + try + { + var args = string.Join(" ", filePaths.Select(f => $"\"{f}\"")); + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "gio", + Arguments = $"trash {args}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + } + }; + + process.Start(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + System.Diagnostics.Debug.WriteLine("gio trash failed, falling back to permanent delete"); + PermanentlyDeleteFiles(filePaths); + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"gio trash not available: {ex.Message}. Falling back to permanent delete."); + PermanentlyDeleteFiles(filePaths); + } + } + + private void MoveFilesToTrashMacOS(IEnumerable filePaths) + { + try + { + var args = string.Join(" ", filePaths.Select(f => $"\"{f}\"")); + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "rm", + Arguments = $"-P {args}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + } + }; + + process.Start(); + process.WaitForExit(); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"macOS trash failed: {ex.Message}"); + PermanentlyDeleteFiles(filePaths); + } + } + + private void MoveFilesToRecycleBinWindows(IEnumerable filePaths) + { + var paths = string.Join("\0", filePaths) + "\0\0"; + var fileOp = new SHFILEOPSTRUCT + { + wFunc = FileOperationType.FO_DELETE, + pFrom = paths, + fFlags = FileOperationFlags.FOF_ALLOWUNDO | FileOperationFlags.FOF_NOCONFIRMATION | FileOperationFlags.FOF_NOERRORUI | FileOperationFlags.FOF_SILENT + }; + + try + { + SHFileOperation(ref fileOp); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Shell delete failed: {ex.Message}. Falling back to permanent delete."); + PermanentlyDeleteFiles(filePaths); + } + } + + [DllImport("shell32.dll", CharSet = CharSet.Auto)] + private static extern int SHFileOperation(ref SHFILEOPSTRUCT lpFileOp); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] + private struct SHFILEOPSTRUCT + { + public IntPtr hwnd; + public FileOperationType wFunc; + [MarshalAs(UnmanagedType.LPStr)] + public string pFrom; + [MarshalAs(UnmanagedType.LPStr)] + public string pTo; + public FileOperationFlags fFlags; + [MarshalAs(UnmanagedType.Bool)] + public bool fAnyOperationsAborted; + public IntPtr hNameMappings; + [MarshalAs(UnmanagedType.LPStr)] + public string lpszProgressTitle; + } + + private enum FileOperationType + { + FO_MOVE = 1, + FO_COPY = 2, + FO_DELETE = 3, + FO_RENAME = 4 + } + + [Flags] + private enum FileOperationFlags + { + FOF_MULTIDESTFILES = 0x0001, + FOF_CONFIRMMOUSE = 0x0002, + FOF_SILENT = 0x0004, + FOF_RENAMEONCOLLISION = 0x0008, + FOF_NOCONFIRMATION = 0x0010, + FOF_WANTMAPPINGHANDLE = 0x0020, + FOF_ALLOWUNDO = 0x0040, + FOF_FILESONLY = 0x0080, + FOF_SIMPLEPROGRESS = 0x0100, + FOF_NOCONFIRMMKDIR = 0x0200, + FOF_NOERRORUI = 0x0400, + FOF_NOCOPYSECURITYATTRIBS = 0x0800, + FOF_NORECURSION = 0x1000, + FOF_NO_UI = FOF_SILENT | FOF_NOCONFIRMATION | FOF_NOERRORUI | FOF_NOCONFIRMMKDIR + } +} diff --git a/src/AStar.Dev.File.App/ViewModels/DeletePendingViewModel.cs b/src/AStar.Dev.File.App/ViewModels/DeletePendingViewModel.cs new file mode 100644 index 0000000..f4bbd2b --- /dev/null +++ b/src/AStar.Dev.File.App/ViewModels/DeletePendingViewModel.cs @@ -0,0 +1,136 @@ +using AStar.Dev.File.App.Data; +using AStar.Dev.File.App.Models; +using AStar.Dev.File.App.Services; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; + +namespace AStar.Dev.File.App.ViewModels; + +public partial class DeletePendingViewModel : ViewModelBase +{ + private readonly IDbContextFactory _dbContextFactory; + private readonly IFileDeleteService _fileDeleteService; + + [ObservableProperty] + private bool _isDeleting; + + [ObservableProperty] + private int _pendingDeleteCount; + + [ObservableProperty] + private string _statusMessage = string.Empty; + + public ObservableCollection PendingDeleteFiles { get; } = []; + + public DeletePendingViewModel( + IDbContextFactory dbContextFactory, + IFileDeleteService fileDeleteService) + { + _dbContextFactory = dbContextFactory; + _fileDeleteService = fileDeleteService; + _ = LoadPendingFilesAsync(); + } + + [RelayCommand] + private async Task TogglePendingDelete(ScannedFileDisplayItem? item) + { + if (item is null) return; + + await using var db = await _dbContextFactory.CreateDbContextAsync(); + var file = await db.ScannedFiles.FindAsync(item.Id); + if (file is not null) + { + file.PendingDelete = !file.PendingDelete; + await db.SaveChangesAsync(); + } + + await LoadPendingFilesAsync(); + } + + [RelayCommand(CanExecute = nameof(CanDeleteAll))] + private async Task DeleteAll() + { + if (PendingDeleteFiles.Count == 0) + return; + + IsDeleting = true; + StatusMessage = "Deleting files..."; + + try + { + var filePaths = PendingDeleteFiles.Select(f => f.FullPath).ToList(); + + await _fileDeleteService.DeleteFilesAsync(filePaths, moveToRecycleBin: true); + + await using var db = await _dbContextFactory.CreateDbContextAsync(); + var ids = PendingDeleteFiles.Select(f => f.Id).ToList(); + var filesToRemove = await db.ScannedFiles.Where(f => ids.Contains(f.Id)).ToListAsync(); + foreach (var file in filesToRemove) + { + db.ScannedFiles.Remove(file); + } + await db.SaveChangesAsync(); + + StatusMessage = $"Successfully deleted {filePaths.Count} file(s) to recycle bin."; + await LoadPendingFilesAsync(); + } + catch (Exception ex) + { + StatusMessage = $"Error deleting files: {ex.Message}"; + } + finally + { + IsDeleting = false; + } + } + + private bool CanDeleteAll() => !IsDeleting && PendingDeleteFiles.Count > 0; + + [RelayCommand] + private async Task ClearMarkings() + { + if (PendingDeleteFiles.Count == 0) + return; + + await using var db = await _dbContextFactory.CreateDbContextAsync(); + var ids = PendingDeleteFiles.Select(f => f.Id).ToList(); + var files = await db.ScannedFiles.Where(f => ids.Contains(f.Id)).ToListAsync(); + foreach (var file in files) + { + file.PendingDelete = false; + } + await db.SaveChangesAsync(); + + StatusMessage = "All delete markings cleared."; + await LoadPendingFilesAsync(); + } + + private async Task LoadPendingFilesAsync() + { + try + { + await using var db = await _dbContextFactory.CreateDbContextAsync(); + var files = await db.ScannedFiles + .Where(f => f.PendingDelete) + .OrderBy(f => f.FolderPath) + .ThenBy(f => f.FileName) + .ToListAsync(); + + PendingDeleteFiles.Clear(); + files.ForEach(file => PendingDeleteFiles.Add(new ScannedFileDisplayItem(file))); + + PendingDeleteCount = PendingDeleteFiles.Count; + DeleteAllCommand.NotifyCanExecuteChanged(); + } + catch (Exception ex) + { + StatusMessage = $"Error loading pending files: {ex.Message}"; + } + } +} diff --git a/src/AStar.Dev.File.App/ViewModels/MainWindowViewModel.cs b/src/AStar.Dev.File.App/ViewModels/MainWindowViewModel.cs index 4bd8460..8df6c80 100644 --- a/src/AStar.Dev.File.App/ViewModels/MainWindowViewModel.cs +++ b/src/AStar.Dev.File.App/ViewModels/MainWindowViewModel.cs @@ -99,6 +99,7 @@ private void ToggleDuplicatesOnly() public ObservableCollection ScannedFiles { get; } = []; public event Action? ViewFileRequested; + public event Action? OpenDeleteWindowRequested; public MainWindowViewModel( IFileScannerService fileScannerService, @@ -173,6 +174,12 @@ private async Task LoadFromDatabase() private bool CanLoadFromDatabase() => !string.IsNullOrWhiteSpace(SelectedFolderPath); + [RelayCommand] + private void OpenDeleteWindow() + { + OpenDeleteWindowRequested?.Invoke(); + } + [RelayCommand(CanExecute = nameof(CanScan))] private async Task StartScan() { diff --git a/src/AStar.Dev.File.App/Views/DeletePendingWindow.axaml b/src/AStar.Dev.File.App/Views/DeletePendingWindow.axaml new file mode 100644 index 0000000..505c89c --- /dev/null +++ b/src/AStar.Dev.File.App/Views/DeletePendingWindow.axaml @@ -0,0 +1,127 @@ + + + + + + + + + + + + + +