diff --git a/Image-Sort.sln b/Image-Sort.sln
index c691b894..34b4f781 100644
--- a/Image-Sort.sln
+++ b/Image-Sort.sln
@@ -31,6 +31,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0F31C99F
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ImageSort.WPF.UiTests", "tests\ImageSort.WPF.UiTests\ImageSort.WPF.UiTests.csproj", "{D72448F9-569A-4BFA-A0C7-79F20BE17F4F}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ImageSort.Avalonia", "src\ImageSort.Avalonia\ImageSort.Avalonia.csproj", "{1086BFB7-F980-4B08-956F-555B06C70992}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -222,6 +224,30 @@ Global
{D72448F9-569A-4BFA-A0C7-79F20BE17F4F}.Release|x64.Build.0 = Release|Any CPU
{D72448F9-569A-4BFA-A0C7-79F20BE17F4F}.Release|x86.ActiveCfg = Release|Any CPU
{D72448F9-569A-4BFA-A0C7-79F20BE17F4F}.Release|x86.Build.0 = Release|Any CPU
+ {1086BFB7-F980-4B08-956F-555B06C70992}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1086BFB7-F980-4B08-956F-555B06C70992}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1086BFB7-F980-4B08-956F-555B06C70992}.Debug|ARM64.ActiveCfg = Debug|Any CPU
+ {1086BFB7-F980-4B08-956F-555B06C70992}.Debug|ARM64.Build.0 = Debug|Any CPU
+ {1086BFB7-F980-4B08-956F-555B06C70992}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {1086BFB7-F980-4B08-956F-555B06C70992}.Debug|x64.Build.0 = Debug|Any CPU
+ {1086BFB7-F980-4B08-956F-555B06C70992}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {1086BFB7-F980-4B08-956F-555B06C70992}.Debug|x86.Build.0 = Debug|Any CPU
+ {1086BFB7-F980-4B08-956F-555B06C70992}.MSIX|Any CPU.ActiveCfg = Debug|Any CPU
+ {1086BFB7-F980-4B08-956F-555B06C70992}.MSIX|Any CPU.Build.0 = Debug|Any CPU
+ {1086BFB7-F980-4B08-956F-555B06C70992}.MSIX|ARM64.ActiveCfg = Debug|Any CPU
+ {1086BFB7-F980-4B08-956F-555B06C70992}.MSIX|ARM64.Build.0 = Debug|Any CPU
+ {1086BFB7-F980-4B08-956F-555B06C70992}.MSIX|x64.ActiveCfg = Debug|Any CPU
+ {1086BFB7-F980-4B08-956F-555B06C70992}.MSIX|x64.Build.0 = Debug|Any CPU
+ {1086BFB7-F980-4B08-956F-555B06C70992}.MSIX|x86.ActiveCfg = Debug|Any CPU
+ {1086BFB7-F980-4B08-956F-555B06C70992}.MSIX|x86.Build.0 = Debug|Any CPU
+ {1086BFB7-F980-4B08-956F-555B06C70992}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1086BFB7-F980-4B08-956F-555B06C70992}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1086BFB7-F980-4B08-956F-555B06C70992}.Release|ARM64.ActiveCfg = Release|Any CPU
+ {1086BFB7-F980-4B08-956F-555B06C70992}.Release|ARM64.Build.0 = Release|Any CPU
+ {1086BFB7-F980-4B08-956F-555B06C70992}.Release|x64.ActiveCfg = Release|Any CPU
+ {1086BFB7-F980-4B08-956F-555B06C70992}.Release|x64.Build.0 = Release|Any CPU
+ {1086BFB7-F980-4B08-956F-555B06C70992}.Release|x86.ActiveCfg = Release|Any CPU
+ {1086BFB7-F980-4B08-956F-555B06C70992}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -235,6 +261,7 @@ Global
{DF9B003E-4B31-494D-8186-15CE8DD69489} = {D1378C69-4AE6-4045-9CE3-1310C1DAFB2D}
{1E511652-E2A5-44EE-B0F6-E8E53FD6AA8F} = {D1378C69-4AE6-4045-9CE3-1310C1DAFB2D}
{D72448F9-569A-4BFA-A0C7-79F20BE17F4F} = {0F31C99F-8F09-4B2C-9411-26532A69AE62}
+ {1086BFB7-F980-4B08-956F-555B06C70992} = {D1378C69-4AE6-4045-9CE3-1310C1DAFB2D}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {0577168B-4A3F-4316-8E17-0E4423B092CC}
diff --git a/src/ImageSort.Avalonia/App.axaml b/src/ImageSort.Avalonia/App.axaml
new file mode 100644
index 00000000..5b11a10a
--- /dev/null
+++ b/src/ImageSort.Avalonia/App.axaml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/ImageSort.Avalonia/App.axaml.cs b/src/ImageSort.Avalonia/App.axaml.cs
new file mode 100644
index 00000000..bcd131af
--- /dev/null
+++ b/src/ImageSort.Avalonia/App.axaml.cs
@@ -0,0 +1,163 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Data.Core;
+using Avalonia.Data.Core.Plugins;
+using System.Linq;
+using Avalonia.Markup.Xaml;
+using ImageSort.Avalonia.ViewModels;
+using ImageSort.Avalonia.Views;
+using System;
+using System.Globalization;
+using System.Reactive.Concurrency;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using ImageSort.DependencyManagement;
+using ImageSort.FileSystem;
+using ImageSort.SettingsManagement;
+using ReactiveUI;
+using Splat;
+using ImageSort.Avalonia.SettingsManagement; // For the new SettingsHelper and Restore extension
+using ImageSort.ViewModels; // Added for core ViewModels
+using System.IO; // Required for FileSystemWatcher
+using ImageSort.Avalonia.FileSystem; // Added for TemporaryRecycleBin
+
+#if !DO_NOT_INCLUDE_UPDATER
+#endif
+
+namespace ImageSort.Avalonia;
+
+public partial class App : Application
+{
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+#if DEBUG && !DEBUG_LOCALIZATION
+ Thread.CurrentThread.CurrentCulture = new CultureInfo("en");
+ Thread.CurrentThread.CurrentUICulture = new CultureInfo("en");
+#endif
+
+ Locator.CurrentMutable.RegisterViewsForViewModels(Assembly.GetExecutingAssembly());
+ Locator.CurrentMutable.RegisterManditoryDependencies();
+ Locator.CurrentMutable.RegisterSettings(settings =>
+ {
+ settings.Add(new GeneralSettingsGroupViewModel());
+ settings.Add(new PinnedFolderSettingsViewModel());
+ settings.Add(new KeyBindingsSettingsGroupViewModel());
+ settings.Add(new MetadataPanelSettings());
+ });
+ Locator.CurrentMutable.RegisterLazySingleton(() => new SettingsViewModel());
+ Locator.CurrentMutable.Register(() => new TemporaryRecycleBin()); // Register TemporaryRecycleBin
+
+ Environment.CurrentDirectory = AppDomain.CurrentDomain.BaseDirectory;
+
+ TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;
+ AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
+
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ DisableAvaloniaDataAnnotationValidation();
+ var fileSystem = Locator.Current.GetService();
+ if (fileSystem == null)
+ {
+ throw new InvalidOperationException("IFileSystem service not registered.");
+ }
+
+ var recycleBin = Locator.Current.GetService();
+ if (recycleBin == null)
+ {
+ throw new InvalidOperationException("IRecycleBin service not registered.");
+ }
+
+ var backgroundScheduler = RxApp.TaskpoolScheduler;
+ var mainThreadScheduler = RxApp.MainThreadScheduler;
+
+ var foldersViewModel = new FoldersViewModel(fileSystem, backgroundScheduler);
+ // Correctly instantiate ImagesViewModel with its actual constructor signature
+ var imagesViewModel = new ImagesViewModel(fileSystem, null); // Pass fileSystem and null for the optional folderWatcherFactory
+ // Pass dependencies directly to ActionsViewModel constructor
+ var actionsViewModel = new ActionsViewModel(imagesViewModel, foldersViewModel, fileSystem);
+
+ var mainWindowViewModel = new MainWindowViewModel(
+ foldersViewModel,
+ imagesViewModel,
+ actionsViewModel,
+ fileSystem,
+ recycleBin,
+ backgroundScheduler);
+
+ desktop.MainWindow = new MainWindow
+ {
+ DataContext = mainWindowViewModel, // Set DataContext
+ ViewModel = mainWindowViewModel // Explicitly set the ViewModel property
+ };
+
+ OnAppStartup();
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+
+ private void DisableAvaloniaDataAnnotationValidation()
+ {
+ var dataValidationPluginsToRemove =
+ BindingPlugins.DataValidators.OfType().ToArray();
+
+ foreach (var plugin in dataValidationPluginsToRemove)
+ {
+ BindingPlugins.DataValidators.Remove(plugin);
+ }
+ }
+
+ private void CurrentDomain_UnhandledException(object? sender, UnhandledExceptionEventArgs e)
+ {
+ System.Diagnostics.Trace.WriteLine(e.ExceptionObject);
+ }
+
+ private void TaskScheduler_UnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
+ {
+ System.Diagnostics.Trace.WriteLine(e.Exception);
+ e.SetObserved();
+ }
+
+#pragma warning disable CS1998
+ private async void OnAppStartup()
+#pragma warning restore CS1998
+ {
+ var settings = Locator.Current.GetService();
+ var mainWindowVm = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow?.DataContext as MainWindowViewModel;
+
+ if (settings != null)
+ {
+ settings.Restore(); // This should set CurrentFolder (if saved) and PinnedFolder paths
+ }
+
+ if (mainWindowVm != null && settings != null)
+ {
+ var pinnedFolderSettings = settings.GetGroup();
+ if (pinnedFolderSettings != null && pinnedFolderSettings.PinnedFolders != null)
+ {
+ mainWindowVm.Folders.AddPinnedFoldersFromPaths(pinnedFolderSettings.PinnedFolders);
+ }
+
+ // After settings are restored and pinned folders loaded,
+ // if CurrentFolder has been set (e.g., by settings restoration),
+ // force it to re-notify. This might help ensure DisplayedFolderItems updates correctly.
+ if (mainWindowVm.Folders.CurrentFolder != null)
+ {
+ var tempCurrentFolder = mainWindowVm.Folders.CurrentFolder;
+ // Temporarily setting to null and back to the original instance
+ // to ensure property change notifications are raised.
+ mainWindowVm.Folders.CurrentFolder = null;
+ mainWindowVm.Folders.CurrentFolder = tempCurrentFolder;
+ }
+ }
+
+#if !DO_NOT_INCLUDE_UPDATER
+#endif
+ }
+}
\ No newline at end of file
diff --git a/src/ImageSort.Avalonia/Assets/avalonia-logo.ico b/src/ImageSort.Avalonia/Assets/avalonia-logo.ico
new file mode 100644
index 00000000..f7da8bb5
Binary files /dev/null and b/src/ImageSort.Avalonia/Assets/avalonia-logo.ico differ
diff --git a/src/ImageSort.Avalonia/Converters/PathToBitmapConverter.cs b/src/ImageSort.Avalonia/Converters/PathToBitmapConverter.cs
new file mode 100644
index 00000000..37277433
--- /dev/null
+++ b/src/ImageSort.Avalonia/Converters/PathToBitmapConverter.cs
@@ -0,0 +1,33 @@
+using Avalonia.Data.Converters;
+using Avalonia.Media.Imaging;
+using System;
+using System.Globalization;
+using System.IO;
+
+namespace ImageSort.Avalonia.Converters
+{
+ public class PathToBitmapConverter : IValueConverter
+ {
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is string path && !string.IsNullOrEmpty(path) && File.Exists(path))
+ {
+ try
+ {
+ return new Bitmap(path);
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"Error loading image {path}: {ex.Message}");
+ return null;
+ }
+ }
+ return null;
+ }
+
+ public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+ }
+}
diff --git a/src/ImageSort.Avalonia/Converters/PathToFilenameConverter.cs b/src/ImageSort.Avalonia/Converters/PathToFilenameConverter.cs
new file mode 100644
index 00000000..384521a1
--- /dev/null
+++ b/src/ImageSort.Avalonia/Converters/PathToFilenameConverter.cs
@@ -0,0 +1,32 @@
+using Avalonia.Data.Converters;
+using System;
+using System.Globalization;
+using System.IO;
+
+namespace ImageSort.Avalonia.Converters
+{
+ public class PathToFilenameConverter : IValueConverter
+ {
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is string path && !string.IsNullOrEmpty(path))
+ {
+ try
+ {
+ return Path.GetFileName(path);
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"Error getting filename from {path}: {ex.Message}");
+ return path; // Fallback to full path on error
+ }
+ }
+ return string.Empty;
+ }
+
+ public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+ }
+}
diff --git a/src/ImageSort.Avalonia/FileSystem/TemporaryRecycleBin.cs b/src/ImageSort.Avalonia/FileSystem/TemporaryRecycleBin.cs
new file mode 100644
index 00000000..98227011
--- /dev/null
+++ b/src/ImageSort.Avalonia/FileSystem/TemporaryRecycleBin.cs
@@ -0,0 +1,101 @@
+using System;
+using System.IO;
+using ImageSort.FileSystem;
+using Splat;
+
+namespace ImageSort.Avalonia.FileSystem
+{
+ public class TemporaryRecycleBin : IRecycleBin
+ {
+ private readonly IFileSystem _fileSystem;
+ private const string RecycleBinFolderName = "_ImageSort_RecycleBin_";
+
+ public TemporaryRecycleBin(IFileSystem fileSystem = null)
+ {
+ _fileSystem = fileSystem ?? Locator.Current.GetService();
+ }
+
+ public IDisposable Send(string path, bool confirmationNeeded = false)
+ {
+ if (string.IsNullOrEmpty(path)) throw new ArgumentNullException(nameof(path));
+ if (!_fileSystem.FileExists(path)) throw new FileNotFoundException("File not found.", path);
+
+ var fileInfo = new FileInfo(path);
+ var parentDirectory = fileInfo.DirectoryName;
+ if (parentDirectory == null) throw new InvalidOperationException("Cannot determine parent directory.");
+
+ var recycleBinDirectory = Path.Combine(parentDirectory, RecycleBinFolderName);
+ _fileSystem.CreateFolder(recycleBinDirectory);
+
+ var fileName = fileInfo.Name;
+ var newPathInRecycleBin = Path.Combine(recycleBinDirectory, fileName);
+
+ // Handle potential name conflicts in the recycle bin
+ int count = 1;
+ string uniqueFileName = fileName;
+ while (_fileSystem.FileExists(newPathInRecycleBin))
+ {
+ uniqueFileName = $"{Path.GetFileNameWithoutExtension(fileName)}_{count++}{Path.GetExtension(fileName)}";
+ newPathInRecycleBin = Path.Combine(recycleBinDirectory, uniqueFileName);
+ }
+
+ _fileSystem.Move(path, newPathInRecycleBin);
+
+ return new Restorer(_fileSystem, newPathInRecycleBin, path);
+ }
+
+ private class Restorer : IDisposable
+ {
+ private readonly IFileSystem _fs;
+ private readonly string _recycledPath;
+ private readonly string _originalPath;
+ private bool _disposed = false;
+
+ public Restorer(IFileSystem fs, string recycledPath, string originalPath)
+ {
+ _fs = fs;
+ _recycledPath = recycledPath;
+ _originalPath = originalPath;
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+
+ try
+ {
+ if (_fs.FileExists(_recycledPath))
+ {
+ // Ensure original directory exists
+ var originalDir = Path.GetDirectoryName(_originalPath);
+ if(originalDir != null && !_fs.DirectoryExists(originalDir))
+ {
+ _fs.CreateFolder(originalDir);
+ }
+
+ // Check if original file name is now taken, if so, find a new name
+ string pathToRestore = _originalPath;
+ int count = 1;
+ while(_fs.FileExists(pathToRestore))
+ {
+ pathToRestore = $"{Path.Combine(Path.GetDirectoryName(_originalPath), Path.GetFileNameWithoutExtension(_originalPath))}_{count++}{Path.GetExtension(_originalPath)}";
+ }
+
+ _fs.Move(_recycledPath, pathToRestore);
+ }
+ }
+ catch (Exception ex)
+ {
+ // Log or handle restoration failure
+ // For now, we'll rethrow as FileRestorationNotPossibleException for consistency,
+ // though the original interface implies this is for when the recycle bin itself fails.
+ throw new FileRestorationNotPossibleException($"Failed to restore file from temporary recycle bin: {ex.Message}", ex);
+ }
+ finally
+ {
+ _disposed = true;
+ }
+ }
+ }
+ }
+}
diff --git a/src/ImageSort.Avalonia/FodyWeavers.xml b/src/ImageSort.Avalonia/FodyWeavers.xml
new file mode 100644
index 00000000..63fc1484
--- /dev/null
+++ b/src/ImageSort.Avalonia/FodyWeavers.xml
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/src/ImageSort.Avalonia/FodyWeavers.xsd b/src/ImageSort.Avalonia/FodyWeavers.xsd
new file mode 100644
index 00000000..f3ac4762
--- /dev/null
+++ b/src/ImageSort.Avalonia/FodyWeavers.xsd
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+ 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.
+
+
+
+
+ A comma-separated list of error codes that can be safely ignored in assembly verification.
+
+
+
+
+ 'false' to turn off automatic generation of the XML Schema file.
+
+
+
+
+
\ No newline at end of file
diff --git a/src/ImageSort.Avalonia/ImageSort.Avalonia.csproj b/src/ImageSort.Avalonia/ImageSort.Avalonia.csproj
new file mode 100644
index 00000000..4dcdd5ee
--- /dev/null
+++ b/src/ImageSort.Avalonia/ImageSort.Avalonia.csproj
@@ -0,0 +1,44 @@
+
+
+ WinExe
+ net8.0
+ enable
+ true
+ app.manifest
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ None
+ All
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ImageSort.Avalonia/Input/ActionKeyBinding.cs b/src/ImageSort.Avalonia/Input/ActionKeyBinding.cs
new file mode 100644
index 00000000..d2446d17
--- /dev/null
+++ b/src/ImageSort.Avalonia/Input/ActionKeyBinding.cs
@@ -0,0 +1,19 @@
+using System.Windows.Input;
+
+namespace ImageSort.Avalonia.Input;
+
+public class ActionKeyBinding
+{
+ public string ActionName { get; }
+ public Hotkey Hotkey { get; set; } // Made settable for reassignment
+ public ICommand Command { get; }
+ public object? CommandParameter { get; }
+
+ public ActionKeyBinding(string actionName, Hotkey hotkey, ICommand command, object? commandParameter = null)
+ {
+ ActionName = actionName;
+ Hotkey = hotkey;
+ Command = command;
+ CommandParameter = commandParameter;
+ }
+}
diff --git a/src/ImageSort.Avalonia/Input/AppAction.cs b/src/ImageSort.Avalonia/Input/AppAction.cs
new file mode 100644
index 00000000..ddc00281
--- /dev/null
+++ b/src/ImageSort.Avalonia/Input/AppAction.cs
@@ -0,0 +1,51 @@
+namespace ImageSort.Avalonia.Input;
+
+public enum AppAction
+{
+ // Image Navigation
+ NextImage,
+ PreviousImage,
+
+ // Action History
+ Undo,
+ Redo,
+
+ // Image Operations
+ MoveImageToCurrentSelectedFolder, // Action for the folder currently selected in the main folder view/grid (e.g. Up Arrow)
+ DeleteImage,
+ RenameImage,
+
+ // Folder Tree Navigation/Selection
+ SelectNextFolderInTree, // e.g., S key (moves selection down in the folder tree)
+ SelectPreviousFolderInTree, // e.g., W key (moves selection up in the folder tree)
+ ExpandSelectedTreeFolder, // e.g., D key (expands the selected folder in the tree)
+ CollapseSelectedTreeFolderOrGoToParent, // e.g., A key (collapses selected folder or navigates to parent of current working folder)
+ SetSelectedTreeFolderAsCurrent, // e.g., Enter key (makes the folder highlighted in the tree the current working folder)
+
+ // Pinned Folder Image Move Operations
+ MoveImageToPinnedFolder1,
+ MoveImageToPinnedFolder2,
+ MoveImageToPinnedFolder3,
+ MoveImageToPinnedFolder4,
+ MoveImageToPinnedFolder5,
+ MoveImageToPinnedFolder6,
+ MoveImageToPinnedFolder7,
+ MoveImageToPinnedFolder8,
+ MoveImageToPinnedFolder9,
+ MoveImageToPinnedFolder0, // For the 10th pinned folder
+
+ // UI Control/Focus
+ FocusSearchBox,
+ ToggleMetadataPanel,
+ // Future: FocusFolders, FocusImages, FocusMetadata if direct focus commands are needed
+
+ // Application Level
+ OpenFolderDialog, // Triggers the 'Open Folder...' functionality (e.g. O key)
+ PinCurrentFolder, // Pins the current working folder (e.g. P key)
+ PinSelectedTreeFolder, // Pins the folder highlighted in the tree (e.g. F key)
+ UnpinSelectedTreeFolder, // Unpins the folder highlighted in the tree (e.g. U key)
+ CreateFolderInSelectedTreeFolder, // Creates a new folder inside the one highlighted in the tree (e.g. C key)
+
+ // Settings (placeholder for now)
+ // OpenSettings,
+}
diff --git a/src/ImageSort.Avalonia/Input/Hotkey.cs b/src/ImageSort.Avalonia/Input/Hotkey.cs
new file mode 100644
index 00000000..c733eb69
--- /dev/null
+++ b/src/ImageSort.Avalonia/Input/Hotkey.cs
@@ -0,0 +1,18 @@
+using Avalonia.Input;
+using System.Text;
+
+namespace ImageSort.Avalonia.Input;
+
+public record Hotkey(Key Key, KeyModifiers Modifiers, AppAction Action)
+{
+ public override string ToString()
+ {
+ var str = new StringBuilder();
+ if (Modifiers.HasFlag(KeyModifiers.Control)) str.Append("Ctrl + ");
+ if (Modifiers.HasFlag(KeyModifiers.Shift)) str.Append("Shift + ");
+ if (Modifiers.HasFlag(KeyModifiers.Alt)) str.Append("Alt + ");
+ if (Modifiers.HasFlag(KeyModifiers.Meta)) str.Append("Meta + "); // For Windows key or Command key
+ str.Append(Key.ToString());
+ return str.ToString();
+ }
+}
diff --git a/src/ImageSort.Avalonia/Input/HotkeyManagerService.cs b/src/ImageSort.Avalonia/Input/HotkeyManagerService.cs
new file mode 100644
index 00000000..46153f57
--- /dev/null
+++ b/src/ImageSort.Avalonia/Input/HotkeyManagerService.cs
@@ -0,0 +1,74 @@
+using Avalonia.Input;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace ImageSort.Avalonia.Input;
+
+public class HotkeyManagerService
+{
+ private readonly List _hotkeys;
+
+ public HotkeyManagerService()
+ {
+ _hotkeys = GetDefaultHotkeys();
+ }
+
+ public AppAction? GetActionFor(Key key, KeyModifiers modifiers)
+ {
+ var hotkey = _hotkeys.FirstOrDefault(h => h.Key == key && h.Modifiers == modifiers);
+ return hotkey?.Action;
+ }
+
+ // TODO: Implement loading and saving of custom hotkeys
+ // public void LoadCustomHotkeys(string filePath) { ... }
+ // public void SaveCustomHotkeys(string filePath) { ... }
+
+ private List GetDefaultHotkeys()
+ {
+ return new List
+ {
+ // Image Navigation
+ new Hotkey(Key.Right, KeyModifiers.None, AppAction.NextImage),
+ new Hotkey(Key.Left, KeyModifiers.None, AppAction.PreviousImage),
+
+ // Action History
+ new Hotkey(Key.Q, KeyModifiers.Control, AppAction.Undo),
+ new Hotkey(Key.E, KeyModifiers.Control, AppAction.Redo),
+
+ // Image Operations
+ new Hotkey(Key.Up, KeyModifiers.None, AppAction.MoveImageToCurrentSelectedFolder),
+ new Hotkey(Key.Down, KeyModifiers.None, AppAction.DeleteImage),
+ new Hotkey(Key.F2, KeyModifiers.None, AppAction.RenameImage),
+
+ // Folder Tree Navigation/Selection
+ new Hotkey(Key.S, KeyModifiers.None, AppAction.SelectNextFolderInTree),
+ new Hotkey(Key.W, KeyModifiers.None, AppAction.SelectPreviousFolderInTree),
+ new Hotkey(Key.D, KeyModifiers.None, AppAction.ExpandSelectedTreeFolder),
+ new Hotkey(Key.A, KeyModifiers.None, AppAction.CollapseSelectedTreeFolderOrGoToParent),
+ new Hotkey(Key.Enter, KeyModifiers.None, AppAction.SetSelectedTreeFolderAsCurrent),
+
+ // Pinned Folder Image Move Operations
+ new Hotkey(Key.D1, KeyModifiers.Control, AppAction.MoveImageToPinnedFolder1),
+ new Hotkey(Key.D2, KeyModifiers.Control, AppAction.MoveImageToPinnedFolder2),
+ new Hotkey(Key.D3, KeyModifiers.Control, AppAction.MoveImageToPinnedFolder3),
+ new Hotkey(Key.D4, KeyModifiers.Control, AppAction.MoveImageToPinnedFolder4),
+ new Hotkey(Key.D5, KeyModifiers.Control, AppAction.MoveImageToPinnedFolder5),
+ new Hotkey(Key.D6, KeyModifiers.Control, AppAction.MoveImageToPinnedFolder6),
+ new Hotkey(Key.D7, KeyModifiers.Control, AppAction.MoveImageToPinnedFolder7),
+ new Hotkey(Key.D8, KeyModifiers.Control, AppAction.MoveImageToPinnedFolder8),
+ new Hotkey(Key.D9, KeyModifiers.Control, AppAction.MoveImageToPinnedFolder9),
+ new Hotkey(Key.D0, KeyModifiers.Control, AppAction.MoveImageToPinnedFolder0),
+
+ // UI Control/Focus
+ new Hotkey(Key.F, KeyModifiers.Control, AppAction.FocusSearchBox),
+ new Hotkey(Key.M, KeyModifiers.Control, AppAction.ToggleMetadataPanel),
+
+ // Application Level
+ new Hotkey(Key.O, KeyModifiers.Control, AppAction.OpenFolderDialog),
+ new Hotkey(Key.P, KeyModifiers.None, AppAction.PinCurrentFolder),
+ new Hotkey(Key.F, KeyModifiers.None, AppAction.PinSelectedTreeFolder), // Note: 'F' without modifier for pinning selected tree folder
+ new Hotkey(Key.U, KeyModifiers.None, AppAction.UnpinSelectedTreeFolder),
+ new Hotkey(Key.C, KeyModifiers.Control, AppAction.CreateFolderInSelectedTreeFolder),
+ };
+ }
+}
diff --git a/src/ImageSort.Avalonia/Program.cs b/src/ImageSort.Avalonia/Program.cs
new file mode 100644
index 00000000..6c90c00c
--- /dev/null
+++ b/src/ImageSort.Avalonia/Program.cs
@@ -0,0 +1,23 @@
+using Avalonia;
+using Avalonia.ReactiveUI;
+using System;
+
+namespace ImageSort.Avalonia;
+
+sealed class Program
+{
+ // Initialization code. Don't use any Avalonia, third-party APIs or any
+ // SynchronizationContext-reliant code before AppMain is called: things aren't initialized
+ // yet and stuff might break.
+ [STAThread]
+ public static void Main(string[] args) => BuildAvaloniaApp()
+ .StartWithClassicDesktopLifetime(args);
+
+ // Avalonia configuration, don't remove; also used by visual designer.
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace()
+ .UseReactiveUI();
+}
diff --git a/src/ImageSort.Avalonia/SettingsManagement/GeneralSettingsGroupViewModel.cs b/src/ImageSort.Avalonia/SettingsManagement/GeneralSettingsGroupViewModel.cs
new file mode 100644
index 00000000..7ff9fec0
--- /dev/null
+++ b/src/ImageSort.Avalonia/SettingsManagement/GeneralSettingsGroupViewModel.cs
@@ -0,0 +1,47 @@
+using ImageSort.SettingsManagement;
+using ImageSort.Localization;
+using ReactiveUI;
+
+namespace ImageSort.Avalonia.SettingsManagement
+{
+ public class GeneralSettingsGroupViewModel : SettingsGroupViewModelBase
+ {
+ public override string Name => "General";
+ public override string Header => Text.GeneralSettingsHeader;
+
+ private bool _darkMode = false;
+ public bool DarkMode
+ {
+ get => _darkMode;
+ set => this.RaiseAndSetIfChanged(ref _darkMode, value);
+ }
+
+ private bool _checkForUpdatesOnStartup = true;
+ public bool CheckForUpdatesOnStartup
+ {
+ get => _checkForUpdatesOnStartup;
+ set => this.RaiseAndSetIfChanged(ref _checkForUpdatesOnStartup, value);
+ }
+
+ private bool _installPrereleaseBuilds = false;
+ public bool InstallPrereleaseBuilds
+ {
+ get => _installPrereleaseBuilds;
+ set => this.RaiseAndSetIfChanged(ref _installPrereleaseBuilds, value);
+ }
+
+ private bool _animateGifs = true;
+ public bool AnimateGifs
+ {
+ get => _animateGifs;
+ set => this.RaiseAndSetIfChanged(ref _animateGifs, value);
+ }
+
+ private bool _animateGifThumbnails = true;
+ public bool AnimateGifThumbnails
+ {
+ get => _animateGifThumbnails;
+ set => this.RaiseAndSetIfChanged(ref _animateGifThumbnails, value);
+ }
+ }
+}
diff --git a/src/ImageSort.Avalonia/SettingsManagement/KeyBindingsSettingsGroupViewModel.cs b/src/ImageSort.Avalonia/SettingsManagement/KeyBindingsSettingsGroupViewModel.cs
new file mode 100644
index 00000000..48671fac
--- /dev/null
+++ b/src/ImageSort.Avalonia/SettingsManagement/KeyBindingsSettingsGroupViewModel.cs
@@ -0,0 +1,52 @@
+using ImageSort.SettingsManagement;
+using ImageSort.Localization;
+using ReactiveUI;
+using Avalonia.Input;
+
+namespace ImageSort.Avalonia.SettingsManagement
+{
+ public record Hotkey(Key Key, KeyModifiers Modifiers);
+
+ public class KeyBindingsSettingsGroupViewModel : SettingsGroupViewModelBase
+ {
+ public override string Name => "KeyBindings";
+ public override string Header => Text.KeyBindingsSettingsHeader;
+
+ private Hotkey _move = new Hotkey(Key.Up, KeyModifiers.None);
+ public Hotkey Move
+ {
+ get => _move;
+ set => this.RaiseAndSetIfChanged(ref _move, value);
+ }
+
+ private Hotkey _delete = new Hotkey(Key.Down, KeyModifiers.None);
+ public Hotkey Delete
+ {
+ get => _delete;
+ set => this.RaiseAndSetIfChanged(ref _delete, value);
+ }
+
+ private Hotkey _rename = new Hotkey(Key.R, KeyModifiers.None);
+ public Hotkey Rename
+ {
+ get => _rename;
+ set => this.RaiseAndSetIfChanged(ref _rename, value);
+ }
+
+ // Add other hotkeys as needed, mirroring the WPF version but using Avalonia types.
+ // For example:
+ private Hotkey _goLeft = new Hotkey(Key.Left, KeyModifiers.None);
+ public Hotkey GoLeft
+ {
+ get => _goLeft;
+ set => this.RaiseAndSetIfChanged(ref _goLeft, value);
+ }
+
+ private Hotkey _goRight = new Hotkey(Key.Right, KeyModifiers.None);
+ public Hotkey GoRight
+ {
+ get => _goRight;
+ set => this.RaiseAndSetIfChanged(ref _goRight, value);
+ }
+ }
+}
diff --git a/src/ImageSort.Avalonia/SettingsManagement/MetadataPanelSettings.cs b/src/ImageSort.Avalonia/SettingsManagement/MetadataPanelSettings.cs
new file mode 100644
index 00000000..86ba6eae
--- /dev/null
+++ b/src/ImageSort.Avalonia/SettingsManagement/MetadataPanelSettings.cs
@@ -0,0 +1,27 @@
+using ImageSort.SettingsManagement;
+using ImageSort.Localization;
+using ReactiveUI;
+
+namespace ImageSort.Avalonia.SettingsManagement
+{
+ public class MetadataPanelSettings : SettingsGroupViewModelBase
+ {
+ public override string Name => "MetadataPanel";
+ public override string Header => Text.MetadataPanelHeader;
+ public override bool IsVisible => false;
+
+ private bool _isExpanded = false;
+ public bool IsExpanded
+ {
+ get => _isExpanded;
+ set => this.RaiseAndSetIfChanged(ref _isExpanded, value);
+ }
+
+ private int _metadataPanelWidth = 300;
+ public int MetadataPanelWidth
+ {
+ get => _metadataPanelWidth;
+ set => this.RaiseAndSetIfChanged(ref _metadataPanelWidth, value);
+ }
+ }
+}
diff --git a/src/ImageSort.Avalonia/SettingsManagement/PinnedFolderSettingsViewModel.cs b/src/ImageSort.Avalonia/SettingsManagement/PinnedFolderSettingsViewModel.cs
new file mode 100644
index 00000000..ec36fa75
--- /dev/null
+++ b/src/ImageSort.Avalonia/SettingsManagement/PinnedFolderSettingsViewModel.cs
@@ -0,0 +1,21 @@
+using ImageSort.SettingsManagement;
+using ImageSort.Localization;
+using ReactiveUI;
+using System.Collections.Generic;
+
+namespace ImageSort.Avalonia.SettingsManagement
+{
+ public class PinnedFolderSettingsViewModel : SettingsGroupViewModelBase
+ {
+ public override string Name => "PinnedFolders";
+ public override string Header => Text.PinnedFoldersSettingsHeader;
+ public override bool IsVisible => false;
+
+ private IEnumerable _pinnedFolders = new List();
+ public IEnumerable PinnedFolders
+ {
+ get => _pinnedFolders;
+ set => this.RaiseAndSetIfChanged(ref _pinnedFolders, value);
+ }
+ }
+}
diff --git a/src/ImageSort.Avalonia/SettingsManagement/SettingsHelper.cs b/src/ImageSort.Avalonia/SettingsManagement/SettingsHelper.cs
new file mode 100644
index 00000000..0425726b
--- /dev/null
+++ b/src/ImageSort.Avalonia/SettingsManagement/SettingsHelper.cs
@@ -0,0 +1,70 @@
+using System;
+using System.IO;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+using ImageSort.SettingsManagement; // Core project
+using ImageSort.Avalonia.SettingsManagement.ShortCutManagement; // To be created
+using Avalonia.Input; // For Avalonia Key and KeyModifiers
+
+namespace ImageSort.Avalonia.SettingsManagement;
+
+internal static class SettingsHelper
+{
+ static SettingsHelper()
+ {
+ if (Environment.GetEnvironmentVariable("UI_TEST") is string uiTest)
+ ConfigFileLocation = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ui_test_config.json");
+ }
+
+ public static string ConfigFileLocation { get; } = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Image Sort",
+#if DEBUG
+ "debug_config.json"
+#else
+ "config.json"
+#endif
+ );
+
+ public static async Task SaveAsync(this SettingsViewModel settings)
+ {
+ var dir = Path.GetDirectoryName(ConfigFileLocation);
+
+ if (dir != null && !Directory.Exists(dir)) Directory.CreateDirectory(dir);
+
+ await using var file = File.Create(ConfigFileLocation);
+
+ var serializerOptions = new JsonSerializerOptions
+ {
+ WriteIndented = true,
+ Converters = { new HotkeyJsonConverter() } // Added for Hotkey serialization
+ };
+
+ await JsonSerializer.SerializeAsync(file, settings.AsDictionary(), serializerOptions).ConfigureAwait(false);
+ }
+
+ public static void Restore(this SettingsViewModel settings)
+ {
+ if (!File.Exists(ConfigFileLocation)) return;
+
+ using var configFile = File.OpenRead(ConfigFileLocation);
+
+ // Deserialize with the custom converter for Hotkeys
+ var serializerOptions = new JsonSerializerOptions
+ {
+ Converters = { new HotkeyJsonConverter() }
+ };
+
+ var configContents = JsonSerializer
+ .DeserializeAsync>>(configFile, serializerOptions).Result;
+
+ // The JsonElement to Value conversion needs to be smarter or rely on the converter.
+ // For now, if HotkeyJsonConverter handles deserialization to Hotkey objects directly during initial deserialize,
+ // this manual JsonElement parsing for Hotkeys might not be needed or needs adjustment.
+ // Let's assume HotkeyJsonConverter handles it for now, simplifying this section.
+ // If not, the JsonElementToValue logic will need to be robustly ported.
+
+ if (configContents != null) settings.RestoreFromDictionary(configContents);
+ }
+}
diff --git a/src/ImageSort.Avalonia/SettingsManagement/ShortCutManagement/Hotkey.cs b/src/ImageSort.Avalonia/SettingsManagement/ShortCutManagement/Hotkey.cs
new file mode 100644
index 00000000..e4fdcf07
--- /dev/null
+++ b/src/ImageSort.Avalonia/SettingsManagement/ShortCutManagement/Hotkey.cs
@@ -0,0 +1,26 @@
+using Avalonia.Input;
+using System;
+using System.Text;
+
+namespace ImageSort.Avalonia.SettingsManagement.ShortCutManagement;
+
+public record Hotkey(Key Key, KeyModifiers Modifiers)
+{
+ public override string ToString()
+ {
+ var str = new StringBuilder();
+
+ if (Modifiers.HasFlag(KeyModifiers.Control))
+ str.Append("Ctrl + ");
+ if (Modifiers.HasFlag(KeyModifiers.Shift))
+ str.Append("Shift + ");
+ if (Modifiers.HasFlag(KeyModifiers.Alt))
+ str.Append("Alt + ");
+ if (Modifiers.HasFlag(KeyModifiers.Meta)) // Meta is often Windows/Command key in Avalonia
+ str.Append("Meta + ");
+
+ str.Append(Key);
+
+ return str.ToString();
+ }
+}
diff --git a/src/ImageSort.Avalonia/SettingsManagement/ShortCutManagement/HotkeyJsonConverter.cs b/src/ImageSort.Avalonia/SettingsManagement/ShortCutManagement/HotkeyJsonConverter.cs
new file mode 100644
index 00000000..e2a240d0
--- /dev/null
+++ b/src/ImageSort.Avalonia/SettingsManagement/ShortCutManagement/HotkeyJsonConverter.cs
@@ -0,0 +1,79 @@
+using Avalonia.Input;
+using System;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace ImageSort.Avalonia.SettingsManagement.ShortCutManagement;
+
+public class HotkeyJsonConverter : JsonConverter
+{
+ public override Hotkey Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType != JsonTokenType.StartObject)
+ {
+ throw new JsonException("Expected StartObject token");
+ }
+
+ Key key = default;
+ KeyModifiers modifiers = default;
+
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndObject)
+ {
+ return new Hotkey(key, modifiers);
+ }
+
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ {
+ throw new JsonException("Expected PropertyName token");
+ }
+
+ string? propertyName = reader.GetString();
+ reader.Read(); // Move to the property value
+
+ switch (propertyName)
+ {
+ case "Key":
+ case "key": // for case-insensitivity from old files
+ if (reader.TryGetInt32(out int keyValue))
+ {
+ key = (Key)keyValue;
+ }
+ else if (Enum.TryParse(reader.GetString(), true, out Key parsedKey))
+ {
+ key = parsedKey;
+ }
+ else
+ {
+ throw new JsonException($"Could not parse Key value: {reader.GetString()}");
+ }
+ break;
+ case "Modifiers":
+ case "modifiers": // for case-insensitivity
+ if (reader.TryGetInt32(out int modifiersValue))
+ {
+ modifiers = (KeyModifiers)modifiersValue;
+ }
+ else if (Enum.TryParse(reader.GetString(), true, out KeyModifiers parsedModifiers))
+ {
+ modifiers = parsedModifiers;
+ }
+ else
+ {
+ throw new JsonException($"Could not parse Modifiers value: {reader.GetString()}");
+ }
+ break;
+ }
+ }
+ throw new JsonException("Expected EndObject token");
+ }
+
+ public override void Write(Utf8JsonWriter writer, Hotkey value, JsonSerializerOptions options)
+ {
+ writer.WriteStartObject();
+ writer.WriteNumber("Key", (int)value.Key);
+ writer.WriteNumber("Modifiers", (int)value.Modifiers);
+ writer.WriteEndObject();
+ }
+}
diff --git a/src/ImageSort.Avalonia/Styles/Controls.xaml b/src/ImageSort.Avalonia/Styles/Controls.xaml
new file mode 100644
index 00000000..dcd15b07
--- /dev/null
+++ b/src/ImageSort.Avalonia/Styles/Controls.xaml
@@ -0,0 +1,4 @@
+
+
+
diff --git a/src/ImageSort.Avalonia/ViewLocator.cs b/src/ImageSort.Avalonia/ViewLocator.cs
new file mode 100644
index 00000000..4c78c662
--- /dev/null
+++ b/src/ImageSort.Avalonia/ViewLocator.cs
@@ -0,0 +1,31 @@
+using System;
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using ImageSort.Avalonia.ViewModels;
+
+namespace ImageSort.Avalonia;
+
+public class ViewLocator : IDataTemplate
+{
+
+ public Control? Build(object? param)
+ {
+ if (param is null)
+ return null;
+
+ var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
+ var type = Type.GetType(name);
+
+ if (type != null)
+ {
+ return (Control)Activator.CreateInstance(type)!;
+ }
+
+ return new TextBlock { Text = "Not Found: " + name };
+ }
+
+ public bool Match(object? data)
+ {
+ return data is ViewModelBase;
+ }
+}
diff --git a/src/ImageSort.Avalonia/ViewModels/MainWindowViewModel.cs b/src/ImageSort.Avalonia/ViewModels/MainWindowViewModel.cs
new file mode 100644
index 00000000..b6ff3e50
--- /dev/null
+++ b/src/ImageSort.Avalonia/ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,321 @@
+using ImageSort.FileSystem; // Added: For IFileSystem, IRecycleBin
+using System.Reactive.Concurrency; // Added: For IScheduler
+using ImageSort.ViewModels;
+using ReactiveUI;
+using Splat;
+using System;
+using System.Reactive;
+using Avalonia.Platform.Storage;
+using System.Linq; // Added for .Any()
+using MsBox.Avalonia; // For message boxes
+using MsBox.Avalonia.Enums; // For message box button/icon enums
+using System.Threading.Tasks; // For Task
+using ImageSort.Avalonia.Views; // For InputDialog
+using ImageSort.Localization; // For Text resource
+using ImageSort.Avalonia.Input; // Required for AppAction
+using System.Reactive.Linq; // Required for ObserveOn
+using Avalonia.Controls.ApplicationLifetimes; // Required for IClassicDesktopStyleApplicationLifetime
+using Avalonia.Controls; // Required for TopLevel
+using Application = Avalonia.Application; // Required for Application.Current
+
+namespace ImageSort.Avalonia.ViewModels;
+
+// Inherit from the core MainViewModel
+public partial class MainWindowViewModel : MainViewModel
+{
+ // Commands for Settings, Keybindings, Credits - specific to Avalonia UI or to be handled here
+ public ReactiveCommand OpenSettings { get; }
+ public ReactiveCommand OpenKeybindings { get; }
+ public ReactiveCommand OpenCredits { get; }
+
+ // Commands for AppActions that are not already in base ViewModels
+ public ReactiveCommand FocusSearchBoxCommand { get; }
+ public ReactiveCommand ToggleMetadataPanelCommand { get; }
+ // PinCurrentFolder is in FoldersViewModel
+ // PinSelectedTreeFolder is in FoldersViewModel
+ // UnpinSelectedTreeFolder is in FoldersViewModel
+ // CreateFolderInSelectedTreeFolder is in FoldersViewModel
+
+ public MainWindowViewModel(FoldersViewModel foldersViewModel, ImagesViewModel imagesViewModel, ActionsViewModel actionsViewModel,
+ IFileSystem fileSystem, IRecycleBin recycleBin, IScheduler backgroundScheduler)
+ : base(foldersViewModel, imagesViewModel, actionsViewModel, fileSystem, recycleBin, backgroundScheduler)
+ {
+ // The base constructor will initialize Actions, Folders, and Images ViewModels.
+ // Any Avalonia-specific initialization for MainWindowViewModel can go here.
+
+ // Initialize Avalonia-specific commands
+ OpenSettings = ReactiveCommand.Create(() => { /* TODO: Implement Avalonia Open Settings Logic */ });
+ OpenKeybindings = ReactiveCommand.Create(() => { /* TODO: Implement Avalonia Open Keybindings Logic */ });
+ OpenCredits = ReactiveCommand.Create(() => { /* TODO: Implement Avalonia Open Credits Logic */ });
+
+ FocusSearchBoxCommand = ReactiveCommand.Create(() =>
+ {
+ // This needs to interact with the UI. One way is via an Interaction.
+ // Or, if the search box is a known element, perhaps a direct focus call if possible (less MVVM).
+ // For now, let's define an interaction.
+ RequestFocusSearchBox.Handle(Unit.Default);
+ });
+
+ ToggleMetadataPanelCommand = ReactiveCommand.Create(() =>
+ {
+ // This will toggle a boolean property that the Metadata panel's visibility is bound to.
+ // Let's assume such a property exists or will be added, e.g., IsMetadataPanelVisible.
+ // For now, let's define an interaction or a direct property change.
+ // Let's add a property to ImagesViewModel for this.
+ Images.IsMetadataVisible = !Images.IsMetadataVisible;
+ });
+
+ // The OpenFolder command in MainViewModel uses PickFolder interaction.
+ // We need to register a handler for that interaction here.
+ PickFolder.RegisterHandler(async interaction =>
+ {
+ var topLevel = TopLevel.GetTopLevel(null); // Ideally, pass a view reference or use a service to get TopLevel
+ if (topLevel == null)
+ {
+ // Attempt to get TopLevel from the Application.Current.ApplicationLifetime if it's an IClassicDesktopStyleApplicationLifetime
+ if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime)
+ {
+ topLevel = desktopLifetime.MainWindow;
+ }
+
+ if (topLevel == null)
+ {
+ interaction.SetOutput(null); // Cannot get TopLevel, so can't show picker
+ return;
+ }
+ }
+
+ var result = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
+ {
+ Title = "Select Folder",
+ AllowMultiple = false
+ });
+
+ if (result.Any())
+ {
+ var uri = result[0].Path;
+ string path = uri.IsAbsoluteUri ? uri.AbsolutePath : uri.OriginalString;
+
+ if (Uri.TryCreate(path, UriKind.Absolute, out var fileUri) && fileUri.IsFile)
+ {
+ path = fileUri.LocalPath;
+ }
+ else if (path.StartsWith("/") && !path.StartsWith("//") && path.Length > 1 && path[1] == ':') // C:/ -> /C:/
+ {
+ path = path.Substring(1);
+ }
+ // For Unix root, path might already be correct as "/"
+ interaction.SetOutput(path);
+ }
+ else
+ {
+ interaction.SetOutput(null);
+ }
+ });
+
+ // Handler for the FoldersViewModel.SelectFolder interaction (used by Pin command)
+ this.Folders.SelectFolder.RegisterHandler(async interaction =>
+ {
+ var topLevel = TopLevel.GetTopLevel(null);
+ if (topLevel == null)
+ {
+ if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime)
+ {
+ topLevel = desktopLifetime.MainWindow;
+ }
+
+ if (topLevel == null)
+ {
+ interaction.SetOutput(null);
+ return;
+ }
+ }
+
+ var result = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
+ {
+ Title = "Select Folder to Pin",
+ AllowMultiple = false
+ });
+
+ if (result.Any())
+ {
+ var uri = result[0].Path;
+ string path = uri.IsAbsoluteUri ? uri.AbsolutePath : uri.OriginalString;
+
+ if (Uri.TryCreate(path, UriKind.Absolute, out var fileUri) && fileUri.IsFile)
+ {
+ path = fileUri.LocalPath;
+ }
+ else if (path.StartsWith("/") && !path.StartsWith("//") && path.Length > 1 && path[1] == ':')
+ {
+ path = path.Substring(1);
+ }
+ interaction.SetOutput(path);
+ }
+ else
+ {
+ interaction.SetOutput(null);
+ }
+ });
+
+ // Handler for ImagesViewModel.PromptForNewFileName
+ this.Images.PromptForNewFileName.RegisterHandler(async interaction =>
+ {
+ var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
+ if (mainWindow == null)
+ {
+ interaction.SetOutput(null);
+ return;
+ }
+
+ var dialog = new InputDialog // Assuming we'll create this view
+ {
+ Title = "Rename File",
+ // We can pass the current name as a default or placeholder if needed
+ };
+
+ var result = await dialog.ShowDialog(mainWindow);
+
+ interaction.SetOutput(result);
+ });
+
+ // Handler for ImagesViewModel.NotifyUserOfError
+ this.Images.NotifyUserOfError.RegisterHandler(async interaction =>
+ {
+ var message = interaction.Input;
+ var box = MessageBoxManager.GetMessageBoxStandard("Error", message, ButtonEnum.Ok, Icon.Error);
+
+ var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
+ if (mainWindow != null)
+ {
+ await box.ShowWindowDialogAsync(mainWindow);
+ }
+ else
+ {
+ await box.ShowAsync(); // Show as a standalone window if main window not found
+ }
+
+ interaction.SetOutput(Unit.Default);
+ });
+
+ // Handler for ActionsViewModel.NotifyUserOfError
+ this.Actions.NotifyUserOfError.RegisterHandler(async interaction =>
+ {
+ var message = interaction.Input;
+ var box = MessageBoxManager.GetMessageBoxStandard("Error", message, ButtonEnum.Ok, Icon.Error);
+
+ var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
+ if (mainWindow != null)
+ {
+ await box.ShowWindowDialogAsync(mainWindow);
+ }
+ else
+ {
+ await box.ShowAsync(); // Show as a standalone window if main window not found
+ }
+
+ interaction.SetOutput(Unit.Default);
+ });
+ }
+
+ // Interaction for focusing the search box
+ public Interaction RequestFocusSearchBox { get; } = new Interaction();
+
+ public void ExecuteAppAction(AppAction action)
+ {
+ switch (action)
+ {
+ // Image Navigation
+ case AppAction.NextImage:
+ Images.SelectNextImage.Execute().Subscribe();
+ break;
+ case AppAction.PreviousImage:
+ Images.SelectPreviousImage.Execute().Subscribe();
+ break;
+
+ // Action History
+ case AppAction.Undo:
+ Actions.Undo.Execute().Subscribe();
+ break;
+ case AppAction.Redo:
+ Actions.Redo.Execute().Subscribe();
+ break;
+
+ // Image Operations
+ case AppAction.MoveImageToCurrentSelectedFolder: // This is the "Up Arrow" or similar action
+ // This action implies the currently selected folder in the main grid/view, not the tree.
+ // This maps to the "Move" command in ActionsViewModel, which uses the FoldersViewModel.CurrentFolder
+ // The "CurrentFolder" in FoldersViewModel is what the image should be moved to.
+ // We need to ensure Folders.SelectedFolder is set appropriately if this action means something different.
+ // For now, assuming it means move to Folders.CurrentFolder (which is the active one for dropping images)
+ Actions.Move.Execute().Subscribe(); // Move uses the current image from ImagesViewModel and current folder from FoldersViewModel
+ break;
+ case AppAction.DeleteImage:
+ Images.DeleteImageCommand.Execute().Subscribe(); // Corrected from Images.DeleteImage
+ break;
+ case AppAction.RenameImage:
+ Images.RenameImage.Execute().Subscribe();
+ break;
+
+ // Folder Tree Navigation/Selection
+ case AppAction.SelectNextFolderInTree:
+ Folders.SelectNextFolder.Execute().Subscribe();
+ break;
+ case AppAction.SelectPreviousFolderInTree:
+ Folders.SelectPreviousFolder.Execute().Subscribe();
+ break;
+ case AppAction.ExpandSelectedTreeFolder:
+ Folders.ExpandSelected.Execute().Subscribe();
+ break;
+ case AppAction.CollapseSelectedTreeFolderOrGoToParent:
+ Folders.CollapseSelectedOrGoToParent.Execute().Subscribe();
+ break;
+ case AppAction.SetSelectedTreeFolderAsCurrent: // Enter on tree item
+ Folders.SetSelectedFolderAsCurrentImplicitly.Execute().Subscribe(); // Or a new command if different behavior needed
+ break;
+
+ // Pinned Folder Image Move Operations
+ case AppAction.MoveImageToPinnedFolder1: Actions.MoveImageToFolder.Execute(Folders.PinnedFolders[0]?.Path).Subscribe(); break;
+ case AppAction.MoveImageToPinnedFolder2: Actions.MoveImageToFolder.Execute(Folders.PinnedFolders[1]?.Path).Subscribe(); break;
+ case AppAction.MoveImageToPinnedFolder3: Actions.MoveImageToFolder.Execute(Folders.PinnedFolders[2]?.Path).Subscribe(); break;
+ case AppAction.MoveImageToPinnedFolder4: Actions.MoveImageToFolder.Execute(Folders.PinnedFolders[3]?.Path).Subscribe(); break;
+ case AppAction.MoveImageToPinnedFolder5: Actions.MoveImageToFolder.Execute(Folders.PinnedFolders[4]?.Path).Subscribe(); break;
+ case AppAction.MoveImageToPinnedFolder6: Actions.MoveImageToFolder.Execute(Folders.PinnedFolders[5]?.Path).Subscribe(); break;
+ case AppAction.MoveImageToPinnedFolder7: Actions.MoveImageToFolder.Execute(Folders.PinnedFolders[6]?.Path).Subscribe(); break;
+ case AppAction.MoveImageToPinnedFolder8: Actions.MoveImageToFolder.Execute(Folders.PinnedFolders[7]?.Path).Subscribe(); break;
+ case AppAction.MoveImageToPinnedFolder9: Actions.MoveImageToFolder.Execute(Folders.PinnedFolders[8]?.Path).Subscribe(); break;
+ case AppAction.MoveImageToPinnedFolder0: Actions.MoveImageToFolder.Execute(Folders.PinnedFolders[9]?.Path).Subscribe(); break;
+ // Note: Need to ensure PinnedFolders collection is accessed safely (e.g., check count or nulls)
+ // This is handled by the ?.Path and the command in ActionsViewModel should handle null path if necessary.
+
+ // UI Control/Focus
+ case AppAction.FocusSearchBox:
+ FocusSearchBoxCommand.Execute().Subscribe();
+ break;
+ case AppAction.ToggleMetadataPanel:
+ ToggleMetadataPanelCommand.Execute().Subscribe();
+ break;
+
+ // Application Level
+ case AppAction.OpenFolderDialog: // O key
+ OpenFolder.Execute().Subscribe(); // This is in MainViewModel base
+ break;
+ case AppAction.PinCurrentFolder: // P key
+ Folders.PinCurrentFolder.Execute().Subscribe();
+ break;
+ case AppAction.PinSelectedTreeFolder: // F key
+ Folders.PinSelected.Execute().Subscribe(); // Corrected from PinSelectedFolder
+ break;
+ case AppAction.UnpinSelectedTreeFolder: // U key
+ Folders.UnpinSelected.Execute().Subscribe(); // Corrected from UnpinSelectedFolder
+ break;
+ case AppAction.CreateFolderInSelectedTreeFolder: // C key
+ Folders.CreateFolderUnderSelected.Execute().Subscribe();
+ break;
+
+ default:
+ // Log or handle unknown action
+ break;
+ }
+ }
+}
diff --git a/src/ImageSort.Avalonia/ViewModels/ViewModelBase.cs b/src/ImageSort.Avalonia/ViewModels/ViewModelBase.cs
new file mode 100644
index 00000000..da87b956
--- /dev/null
+++ b/src/ImageSort.Avalonia/ViewModels/ViewModelBase.cs
@@ -0,0 +1,21 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using ReactiveUI; // Add this for IActivatableViewModel
+using System.Reactive.Disposables;
+
+namespace ImageSort.Avalonia.ViewModels;
+
+public class ViewModelBase : ObservableObject, IActivatableViewModel // Implement IActivatableViewModel
+{
+ // Add the required ViewModelActivator property
+ public ViewModelActivator Activator { get; } = new ViewModelActivator();
+
+ public ViewModelBase()
+ {
+ this.WhenActivated(disposables =>
+ {
+ // This is where you would typically put activation logic for the ViewModel
+ // For now, it can be empty if there's no specific base activation logic.
+ Disposable.Create(() => { /* Deactivation logic */ }).DisposeWith(disposables);
+ });
+ }
+}
diff --git a/src/ImageSort.Avalonia/Views/ActionsView.axaml b/src/ImageSort.Avalonia/Views/ActionsView.axaml
new file mode 100644
index 00000000..b935db7c
--- /dev/null
+++ b/src/ImageSort.Avalonia/Views/ActionsView.axaml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/ImageSort.Avalonia/Views/ActionsView.axaml.cs b/src/ImageSort.Avalonia/Views/ActionsView.axaml.cs
new file mode 100644
index 00000000..116d449d
--- /dev/null
+++ b/src/ImageSort.Avalonia/Views/ActionsView.axaml.cs
@@ -0,0 +1,12 @@
+using Avalonia.ReactiveUI;
+using ImageSort.ViewModels;
+
+namespace ImageSort.Avalonia.Views;
+
+public partial class ActionsView : ReactiveUserControl
+{
+ public ActionsView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/ImageSort.Avalonia/Views/FolderTreeItemView.axaml b/src/ImageSort.Avalonia/Views/FolderTreeItemView.axaml
new file mode 100644
index 00000000..e90b69f2
--- /dev/null
+++ b/src/ImageSort.Avalonia/Views/FolderTreeItemView.axaml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/src/ImageSort.Avalonia/Views/FolderTreeItemView.axaml.cs b/src/ImageSort.Avalonia/Views/FolderTreeItemView.axaml.cs
new file mode 100644
index 00000000..2e68d625
--- /dev/null
+++ b/src/ImageSort.Avalonia/Views/FolderTreeItemView.axaml.cs
@@ -0,0 +1,15 @@
+using Avalonia.ReactiveUI;
+using ImageSort.ViewModels;
+using ReactiveUI;
+
+namespace ImageSort.Avalonia.Views
+{
+ public partial class FolderTreeItemView : ReactiveUserControl
+ {
+ public FolderTreeItemView()
+ {
+ InitializeComponent();
+ this.WhenActivated(disposables => { });
+ }
+ }
+}
diff --git a/src/ImageSort.Avalonia/Views/FoldersView.axaml b/src/ImageSort.Avalonia/Views/FoldersView.axaml
new file mode 100644
index 00000000..cfd4a113
--- /dev/null
+++ b/src/ImageSort.Avalonia/Views/FoldersView.axaml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ImageSort.Avalonia/Views/FoldersView.axaml.cs b/src/ImageSort.Avalonia/Views/FoldersView.axaml.cs
new file mode 100644
index 00000000..1865b72e
--- /dev/null
+++ b/src/ImageSort.Avalonia/Views/FoldersView.axaml.cs
@@ -0,0 +1,12 @@
+using Avalonia.ReactiveUI;
+using ImageSort.ViewModels;
+
+namespace ImageSort.Avalonia.Views;
+
+public partial class FoldersView : ReactiveUserControl
+{
+ public FoldersView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/ImageSort.Avalonia/Views/ImagesView.axaml b/src/ImageSort.Avalonia/Views/ImagesView.axaml
new file mode 100644
index 00000000..1a3d650c
--- /dev/null
+++ b/src/ImageSort.Avalonia/Views/ImagesView.axaml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ImageSort.Avalonia/Views/ImagesView.axaml.cs b/src/ImageSort.Avalonia/Views/ImagesView.axaml.cs
new file mode 100644
index 00000000..e57cb958
--- /dev/null
+++ b/src/ImageSort.Avalonia/Views/ImagesView.axaml.cs
@@ -0,0 +1,12 @@
+using Avalonia.ReactiveUI;
+using ImageSort.ViewModels;
+
+namespace ImageSort.Avalonia.Views;
+
+public partial class ImagesView : ReactiveUserControl
+{
+ public ImagesView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/ImageSort.Avalonia/Views/InputDialog.axaml b/src/ImageSort.Avalonia/Views/InputDialog.axaml
new file mode 100644
index 00000000..ded67b77
--- /dev/null
+++ b/src/ImageSort.Avalonia/Views/InputDialog.axaml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ImageSort.Avalonia/Views/InputDialog.axaml.cs b/src/ImageSort.Avalonia/Views/InputDialog.axaml.cs
new file mode 100644
index 00000000..738a2cde
--- /dev/null
+++ b/src/ImageSort.Avalonia/Views/InputDialog.axaml.cs
@@ -0,0 +1,63 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using System.Threading.Tasks;
+
+namespace ImageSort.Avalonia.Views;
+
+public partial class InputDialog : Window
+{
+ public string InputText { get; private set; }
+
+ public InputDialog()
+ {
+ InitializeComponent();
+ OkButton.Click += (_, __) => CloseDialog(true);
+ CancelButton.Click += (_, __) => CloseDialog(false);
+ InputTextBox.KeyDown += (s, e) =>
+ {
+ if (e.Key == Key.Enter)
+ {
+ CloseDialog(true);
+ }
+ else if (e.Key == Key.Escape)
+ {
+ CloseDialog(false);
+ }
+ };
+ }
+
+ private void CloseDialog(bool success)
+ {
+ if (success)
+ {
+ InputText = InputTextBox.Text;
+ Close(InputText);
+ }
+ else
+ {
+ Close(null);
+ }
+ }
+
+ // Optional: Method to set initial text or message
+ public void SetParameters(string title, string message, string defaultInput = null)
+ {
+ Title = title;
+ MessageTextBlock.Text = message;
+ if (defaultInput != null)
+ {
+ InputTextBox.Text = defaultInput;
+ }
+ InputTextBox.Focus();
+ InputTextBox.SelectAll();
+ }
+
+ // Static method to show the dialog easily
+ public static async Task ShowAsync(Window parent, string title, string message, string defaultInput = null)
+ {
+ var dialog = new InputDialog();
+ dialog.SetParameters(title, message, defaultInput);
+ return await dialog.ShowDialog(parent);
+ }
+}
diff --git a/src/ImageSort.Avalonia/Views/MainWindow.axaml b/src/ImageSort.Avalonia/Views/MainWindow.axaml
new file mode 100644
index 00000000..ff3a91e4
--- /dev/null
+++ b/src/ImageSort.Avalonia/Views/MainWindow.axaml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ImageSort.Avalonia/Views/MainWindow.axaml.cs b/src/ImageSort.Avalonia/Views/MainWindow.axaml.cs
new file mode 100644
index 00000000..b66d54e7
--- /dev/null
+++ b/src/ImageSort.Avalonia/Views/MainWindow.axaml.cs
@@ -0,0 +1,91 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input; // Required for KeyEventArgs
+using Avalonia.ReactiveUI; // Required for ReactiveWindow
+using ImageSort.Avalonia.Input; // Required for HotkeyManagerService and AppAction
+using ImageSort.Avalonia.ViewModels;
+using ReactiveUI;
+using System;
+using System.Reactive.Disposables;
+using System.Reactive.Linq; // Required for Observable.FromEventPattern
+
+namespace ImageSort.Avalonia.Views;
+
+public partial class MainWindow : ReactiveWindow
+{
+ private HotkeyManagerService? _hotkeyManagerService; // Changed from HotkeyService
+
+ public MainWindow()
+ {
+ InitializeComponent();
+#if DEBUG
+ this.AttachDevTools();
+#endif
+
+ this.WhenActivated(disposableRegistration =>
+ {
+ _hotkeyManagerService = new HotkeyManagerService(); // Instantiate HotkeyManagerService
+
+ if (ViewModel != null)
+ {
+ // Subscribe to KeyDown event
+ Observable.FromEventPattern(this, nameof(KeyDown))
+ .Subscribe(args => MainWindow_KeyDown(args.Sender, args.EventArgs))
+ .DisposeWith(disposableRegistration);
+
+ // Handle RequestFocusSearchBox interaction
+ ViewModel.RequestFocusSearchBox.RegisterHandler(interaction =>
+ {
+ // Assuming ImagesView is accessible and contains SearchTermTextBox
+ // This might need adjustment based on your actual view structure.
+ // If ImagesView is a direct child or accessible through a property:
+ var imagesView = this.FindControl("ImagesView"); // Ensure ImagesView has x:Name="ImagesView"
+ if (imagesView != null)
+ {
+ var searchBox = imagesView.FindControl("SearchTermTextBox");
+ searchBox?.Focus();
+ }
+ interaction.SetOutput(System.Reactive.Unit.Default);
+ })
+ .DisposeWith(disposableRegistration);
+ }
+ });
+ }
+
+ private void MainWindow_KeyDown(object? sender, global::Avalonia.Input.KeyEventArgs e) // Use global:: to ensure correct KeyEventArgs
+ {
+ if (ViewModel == null || _hotkeyManagerService == null) return;
+
+ // Check if focus is on a TextBox or similar input control where typing should be preserved.
+ // This is a basic check; more sophisticated focus management might be needed.
+ if (e.Source is IInputElement inputElement)
+ {
+ if (inputElement is TextBox || inputElement is AutoCompleteBox) // Add other input types if necessary
+ {
+ // Allow specific hotkeys even in textboxes (e.g., Ctrl+Z for Undo)
+ // For now, let's assume most hotkeys shouldn't trigger if a textbox has focus,
+ // unless they are common text editing shortcuts (which we aren't handling here yet)
+ // or explicitly designed to work globally.
+ // This logic can be refined.
+ // For instance, Ctrl+F for FocusSearchBox should work.
+ var potentialAction = _hotkeyManagerService.GetActionFor(e.Key, e.KeyModifiers);
+ if (potentialAction != AppAction.FocusSearchBox &&
+ potentialAction != AppAction.Undo &&
+ potentialAction != AppAction.Redo) // Allow Undo/Redo
+ {
+ // If not a globally desired action like FocusSearchBox, Undo, Redo, don't process other hotkeys.
+ // This prevents, for example, 'W' in a textbox from triggering 'SelectPreviousFolderInTree'.
+ return;
+ }
+ }
+ }
+
+ var action = _hotkeyManagerService.GetActionFor(e.Key, e.KeyModifiers);
+
+ if (action.HasValue)
+ {
+ ViewModel.ExecuteAppAction(action.Value);
+ e.Handled = true; // Mark as handled if an action was executed
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ImageSort.Avalonia/Views/Metadata/MetadataView.axaml b/src/ImageSort.Avalonia/Views/Metadata/MetadataView.axaml
new file mode 100644
index 00000000..f0ed3609
--- /dev/null
+++ b/src/ImageSort.Avalonia/Views/Metadata/MetadataView.axaml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ImageSort.Avalonia/Views/Metadata/MetadataView.axaml.cs b/src/ImageSort.Avalonia/Views/Metadata/MetadataView.axaml.cs
new file mode 100644
index 00000000..3f851b12
--- /dev/null
+++ b/src/ImageSort.Avalonia/Views/Metadata/MetadataView.axaml.cs
@@ -0,0 +1,18 @@
+using Avalonia.ReactiveUI;
+using ImageSort.ViewModels.Metadata;
+using ReactiveUI;
+
+namespace ImageSort.Avalonia.Views.Metadata;
+
+public partial class MetadataView : ReactiveUserControl
+{
+ public MetadataView()
+ {
+ InitializeComponent();
+
+ this.WhenActivated(d =>
+ {
+ // Add bindings here if needed, or rely on XAML bindings
+ });
+ }
+}
diff --git a/src/ImageSort.Avalonia/app.manifest b/src/ImageSort.Avalonia/app.manifest
new file mode 100644
index 00000000..171b449d
--- /dev/null
+++ b/src/ImageSort.Avalonia/app.manifest
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ImageSort.Localization/Text.Designer.cs b/src/ImageSort.Localization/Text.Designer.cs
index fef8d90c..2b732b2d 100644
--- a/src/ImageSort.Localization/Text.Designer.cs
+++ b/src/ImageSort.Localization/Text.Designer.cs
@@ -384,6 +384,15 @@ public static string MoveActionMessage {
return ResourceManager.GetString("MoveActionMessage", resourceCulture);
}
}
+
+ ///
+ /// Looks up a localized string similar to Cannot move the image. A file named "{FileName}" already exists at the destination..
+ ///
+ public static string MoveActionFileExistsError {
+ get {
+ return ResourceManager.GetString("MoveActionFileExistsError", resourceCulture);
+ }
+ }
///
/// Looks up a localized string similar to Move the selected pinned folder down.
diff --git a/src/ImageSort.Localization/Text.de-DE.resx b/src/ImageSort.Localization/Text.de-DE.resx
index f736f320..f0282d5c 100644
--- a/src/ImageSort.Localization/Text.de-DE.resx
+++ b/src/ImageSort.Localization/Text.de-DE.resx
@@ -175,6 +175,9 @@ Hinweis: In den meisten Fällen ist die Datei beschädigt
{FileName} nach {Directory} verschieben
+
+ Das Bild kann nicht verschoben werden. Eine Datei mit dem Namen "{FileName}" existiert bereits am Zielort.
+
Welchen Namen soll der Ordner haben?
diff --git a/src/ImageSort.Localization/Text.resx b/src/ImageSort.Localization/Text.resx
index 1ffccfb6..23b94ff3 100644
--- a/src/ImageSort.Localization/Text.resx
+++ b/src/ImageSort.Localization/Text.resx
@@ -175,6 +175,9 @@ Note: Usually this is because the file is damaged
Move {FileName} to {Directory}
+
+ Cannot move the image. A file named "{FileName}" already exists at the destination.
+
What name should the folder have?
diff --git a/src/ImageSort/Actions/MoveAction.cs b/src/ImageSort/Actions/MoveAction.cs
index 087dda0d..9976a1f9 100644
--- a/src/ImageSort/Actions/MoveAction.cs
+++ b/src/ImageSort/Actions/MoveAction.cs
@@ -44,6 +44,10 @@ public MoveAction(string file, string toFolder, IFileSystem fileSystem,
public void Act()
{
+ if (fileSystem.FileExists(newDestination))
+ {
+ throw new IOException(Text.MoveActionFileExistsError.Replace("{FileName}", newDestination, StringComparison.OrdinalIgnoreCase));
+ }
fileSystem.Move(oldDestination, newDestination);
notifyAct?.Invoke(oldDestination, newDestination);
diff --git a/src/ImageSort/ImageSort.csproj b/src/ImageSort/ImageSort.csproj
index eedf1738..45d5a61c 100644
--- a/src/ImageSort/ImageSort.csproj
+++ b/src/ImageSort/ImageSort.csproj
@@ -4,23 +4,20 @@
net8.0
Debug;Release;MSIX
win-x86;win-x64;win-arm64
+
-
-
-
- All
+
+ all
+
+
-
-
- false
-
diff --git a/src/ImageSort/ViewModels/ActionsViewModel.cs b/src/ImageSort/ViewModels/ActionsViewModel.cs
index 51c78d4a..b31ed241 100644
--- a/src/ImageSort/ViewModels/ActionsViewModel.cs
+++ b/src/ImageSort/ViewModels/ActionsViewModel.cs
@@ -1,10 +1,13 @@
using ImageSort.Actions;
+using ImageSort.FileSystem; // Added for IFileSystem
using ImageSort.Localization;
using ReactiveUI;
+using Splat; // Added for Locator
using System;
using System.Collections.Generic;
using System.Reactive;
using System.Reactive.Linq;
+using System.Reactive.Subjects; // Added for Subject
namespace ImageSort.ViewModels;
@@ -13,12 +16,22 @@ public class ActionsViewModel : ReactiveObject
private readonly Stack done = new Stack();
private readonly Stack undone = new Stack();
+ private readonly ImagesViewModel imagesViewModel; // Added
+ private readonly FoldersViewModel foldersViewModel; // Added
+ private readonly IFileSystem fileSystem; // Added
+
private readonly ObservableAsPropertyHelper lastDone;
public string LastDone => lastDone.Value;
private readonly ObservableAsPropertyHelper lastUndone;
public string LastUndone => lastUndone.Value;
+ private readonly ObservableAsPropertyHelper _canUndo;
+ public bool CanUndo => _canUndo.Value;
+
+ private readonly ObservableAsPropertyHelper _canRedo;
+ public bool CanRedo => _canRedo.Value;
+
public Interaction NotifyUserOfError { get; } = new Interaction();
public ReactiveCommand Execute { get; }
@@ -26,26 +39,43 @@ public class ActionsViewModel : ReactiveObject
public ReactiveCommand Redo { get; }
public ReactiveCommand Clear { get; }
- public ActionsViewModel()
+ // New commands for moving images
+ public ReactiveCommand Move { get; private set; } // For moving to current selected folder (grid)
+ public ReactiveCommand MoveImageToFolder { get; private set; } // For moving to a specific folder path (e.g., pinned folders)
+
+ private readonly Subject _historyChangedSignal = new Subject();
+
+ public ActionsViewModel(ImagesViewModel imagesViewModel = null,
+ FoldersViewModel foldersViewModel = null,
+ IFileSystem fileSystem = null)
{
+ this.imagesViewModel = imagesViewModel ?? Locator.Current.GetService();
+ this.foldersViewModel = foldersViewModel ?? Locator.Current.GetService();
+ this.fileSystem = fileSystem ?? Locator.Current.GetService();
+
+ var canUndoObservable = _historyChangedSignal
+ .Select(_ => done.Count > 0)
+ .StartWith(done.Count > 0);
+
+ var canRedoObservable = _historyChangedSignal
+ .Select(_ => undone.Count > 0)
+ .StartWith(undone.Count > 0);
+
Execute = ReactiveCommand.CreateFromTask(async action =>
{
try
{
action.Act();
+ done.Push(action);
+ undone.Clear(); // Clear redo stack on new action
+ _historyChangedSignal.OnNext(Unit.Default);
}
catch (Exception ex)
{
await NotifyUserOfError.Handle(Text.CouldNotActErrorText
.Replace("{ErrorMessage}", ex.Message, StringComparison.OrdinalIgnoreCase)
.Replace("{ActMessage}", action.DisplayName, StringComparison.OrdinalIgnoreCase));
-
- return;
}
-
- done.Push(action);
-
- undone.Clear();
});
Undo = ReactiveCommand.CreateFromTask(async () =>
@@ -55,19 +85,19 @@ await NotifyUserOfError.Handle(Text.CouldNotActErrorText
try
{
action.Revert();
+ undone.Push(action);
+ _historyChangedSignal.OnNext(Unit.Default);
}
catch (Exception ex)
{
await NotifyUserOfError.Handle(Text.CouldNotUndoErrorText
.Replace("{ErrorMessage}", ex.Message, StringComparison.OrdinalIgnoreCase)
.Replace("{ActMessage}", action.DisplayName, StringComparison.OrdinalIgnoreCase));
-
- return;
+ // Signal change even if revert fails, because 'done' stack changed.
+ _historyChangedSignal.OnNext(Unit.Default);
}
-
- undone.Push(action);
}
- });
+ }, canUndoObservable);
Redo = ReactiveCommand.CreateFromTask(async () =>
{
@@ -75,45 +105,133 @@ await NotifyUserOfError.Handle(Text.CouldNotUndoErrorText
{
try
{
- action.Act();
+ action.Act(); // Re-apply the action
+ done.Push(action);
+ _historyChangedSignal.OnNext(Unit.Default);
}
catch (Exception ex)
{
await NotifyUserOfError.Handle(Text.CouldNotRedoErrorText
.Replace("{ErrorMessage}", ex.Message, StringComparison.OrdinalIgnoreCase)
.Replace("{ActMessage}", action.DisplayName, StringComparison.OrdinalIgnoreCase));
-
- return;
+ // Signal change even if re-act fails, because 'undone' stack changed.
+ _historyChangedSignal.OnNext(Unit.Default);
}
-
- done.Push(action);
}
- });
+ }, canRedoObservable);
Clear = ReactiveCommand.Create(() =>
{
done.Clear();
undone.Clear();
+ _historyChangedSignal.OnNext(Unit.Default);
});
- var historyChanges = Execute.Merge(Undo).Merge(Redo).Merge(Clear);
+ InitializeMoveCommands();
+
+ _canUndo = canUndoObservable.ToProperty(this, vm => vm.CanUndo);
+ _canRedo = canRedoObservable.ToProperty(this, vm => vm.CanRedo);
+
+ lastDone = _historyChangedSignal
+ .Select(_ => done.TryPeek(out var action) ? action.DisplayName : null)
+ .StartWith(done.TryPeek(out var action) ? action.DisplayName : null)
+ .ToProperty(this, vm => vm.LastDone);
+
+ lastUndone = _historyChangedSignal
+ .Select(_ => undone.TryPeek(out var action) ? action.DisplayName : null)
+ .StartWith(undone.TryPeek(out var undoneAction) ? undoneAction.DisplayName : null)
+ .ToProperty(this, vm => vm.LastUndone);
+ }
- lastDone = historyChanges
- .Select(_ =>
+ private void InitializeMoveCommands()
+ {
+ // Observable that is true when both imagesViewModel and foldersViewModel are non-null.
+ var viewModelsAvailable = this.WhenAnyValue(
+ x => x.imagesViewModel,
+ x => x.foldersViewModel,
+ (imgVm, folVm) => imgVm != null && folVm != null)
+ .DistinctUntilChanged();
+
+ // canMove: True if view models are available, an image is selected, and a current folder path is set.
+ var canMove = viewModelsAvailable
+ .Select(areAvailable =>
{
- if (done.TryPeek(out var action)) return action.DisplayName;
+ if (!areAvailable) return Observable.Return(false);
- return null;
+ // Defer creation of the inner observable until subscription,
+ // and after we know imagesViewModel and foldersViewModel are not null.
+ return Observable.Defer(() =>
+ {
+ // Re-check for safety, though 'areAvailable' should mean they are not null.
+ if (this.imagesViewModel == null || this.foldersViewModel == null) return Observable.Return(false);
+
+ var selectedImageObservable = this.imagesViewModel.WhenAnyValue(vm => vm.SelectedImage)
+ .Select(img => !string.IsNullOrEmpty(img));
+
+ var currentFolderPathObservable = this.foldersViewModel.WhenAnyValue(vm => vm.CurrentFolder)
+ .Select(currentFolder => currentFolder != null && !string.IsNullOrEmpty(currentFolder.Path))
+ .DistinctUntilChanged();
+
+ return Observable.CombineLatest(
+ selectedImageObservable,
+ currentFolderPathObservable,
+ (imageSelected, pathValid) => imageSelected && pathValid
+ );
+ });
})
- .ToProperty(this, vm => vm.LastDone);
+ .Switch() // Switch to the observable emitted by Select
+ .StartWith(false)
+ .DistinctUntilChanged();
- lastUndone = historyChanges
- .Select(_ =>
+ Move = ReactiveCommand.CreateFromTask(async () =>
+ {
+ if (this.imagesViewModel?.SelectedImage == null || this.foldersViewModel?.CurrentFolder?.Path == null || this.fileSystem == null) return;
+
+ var moveAction = new MoveAction(
+ this.imagesViewModel.SelectedImage,
+ this.foldersViewModel.CurrentFolder.Path,
+ this.fileSystem,
+ this.imagesViewModel.OnImageMoved,
+ (newPath, oldPath) => this.imagesViewModel.OnImageMoved(newPath, oldPath)
+ );
+ await Execute.Execute(moveAction);
+ }, canMove);
+
+ // Observable that is true when imagesViewModel is non-null.
+ var imageViewModelAvailable = this.WhenAnyValue(x => x.imagesViewModel)
+ .Select(imgVm => imgVm != null)
+ .DistinctUntilChanged();
+
+ // canMoveToFolder: True if imagesViewModel is available and an image is selected.
+ var canMoveToFolder = imageViewModelAvailable
+ .Select(imgVmAvailable =>
{
- if (undone.TryPeek(out var action)) return action.DisplayName;
+ if (!imgVmAvailable) return Observable.Return(false);
+
+ return Observable.Defer(() =>
+ {
+ if (this.imagesViewModel == null) return Observable.Return(false);
- return null;
+ return this.imagesViewModel.WhenAnyValue(vm => vm.SelectedImage)
+ .Select(img => !string.IsNullOrEmpty(img));
+ });
})
- .ToProperty(this, vm => vm.LastUndone);
+ .Switch()
+ .StartWith(false)
+ .DistinctUntilChanged();
+
+ MoveImageToFolder = ReactiveCommand.CreateFromTask(async (folderPath) =>
+ {
+ if (this.imagesViewModel?.SelectedImage == null || string.IsNullOrEmpty(folderPath) || this.fileSystem == null) return;
+
+ var moveAction = new MoveAction(
+ this.imagesViewModel.SelectedImage,
+ folderPath,
+ this.fileSystem,
+ this.imagesViewModel.OnImageMoved,
+ (newPath, oldPath) => this.imagesViewModel.OnImageMoved(newPath, oldPath)
+ );
+ await Execute.Execute(moveAction);
+ }, canMoveToFolder);
}
}
\ No newline at end of file
diff --git a/src/ImageSort/ViewModels/FolderTreeItemViewModel.cs b/src/ImageSort/ViewModels/FolderTreeItemViewModel.cs
index cbe0c5c7..59ecaa0e 100644
--- a/src/ImageSort/ViewModels/FolderTreeItemViewModel.cs
+++ b/src/ImageSort/ViewModels/FolderTreeItemViewModel.cs
@@ -1,212 +1,374 @@
-using DynamicData;
-using DynamicData.Binding;
+#nullable enable
+
+using DynamicData;
using ImageSort.FileSystem;
-using ImageSort.Helpers;
+using ImageSort.Helpers; // Added for PathEquals
using ReactiveUI;
using Splat;
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
-using System.Reactive;
using System.Reactive.Concurrency;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using System.Threading.Tasks;
+using System.Collections.Generic;
namespace ImageSort.ViewModels;
-public class FolderTreeItemViewModel : ReactiveObject
+public class FolderTreeItemViewModel : ReactiveObject, IDisposable
{
private readonly CompositeDisposable disposableRegistration = new CompositeDisposable();
- private readonly IFileSystem fileSystem;
- private readonly IScheduler backgroundScheduler;
- private readonly Func folderWatcherFactory;
- private readonly FileSystemWatcher folderWatcher;
+ private readonly IFileSystem _fileSystem;
+ private readonly IScheduler _backgroundScheduler;
+ private readonly Func _folderWatcherFactory;
+ private readonly FileSystemWatcher? _folderWatcher;
- private bool _isCurrentFolder;
+ private bool _isExpanded;
+ public bool IsExpanded
+ {
+ get => _isExpanded;
+ set => this.RaiseAndSetIfChanged(ref _isExpanded, value);
+ }
+
+ private bool _childrenLoaded = false;
+
+ public bool IsPlaceholder { get; init; } = false;
+ private bool _isSelected;
+ public bool IsSelected
+ {
+ get => _isSelected;
+ set => this.RaiseAndSetIfChanged(ref _isSelected, value);
+ }
+
+ private bool _isCurrentFolder;
public bool IsCurrentFolder
{
get => _isCurrentFolder;
set => this.RaiseAndSetIfChanged(ref _isCurrentFolder, value);
}
- private bool _isVisible;
-
+ private bool _isVisible = true;
public bool IsVisible
{
get => _isVisible;
set => this.RaiseAndSetIfChanged(ref _isVisible, value);
}
- private string _path;
-
+ private string _path = string.Empty;
public string Path
{
get => _path;
- set => this.RaiseAndSetIfChanged(ref _path, value);
+ set => this.RaiseAndSetIfChanged(ref _path, value);
}
- private readonly ObservableAsPropertyHelper _folderName;
- public string FolderName => _folderName.Value;
+ private readonly ObservableAsPropertyHelper _folderNameOaph;
+ public string FolderName => _folderNameOaph.Value;
- private readonly SourceList subFolders;
+ private readonly SourceList _subFoldersSourceList;
- private readonly ReadOnlyObservableCollection _children;
- public ReadOnlyObservableCollection Children => _children;
+ private readonly ReadOnlyObservableCollection _childrenBinding;
+ public ReadOnlyObservableCollection Children => _childrenBinding;
- public ReactiveCommand CreateFolder { get; }
+ public FolderTreeItemViewModel? Parent { get; }
- public FolderTreeItemViewModel(IFileSystem fileSystem = null, Func folderWatcherFactory = null, IScheduler backgroundScheduler = null)
+ public ReactiveCommand CreateFolderCommand { get; }
+
+ // Primary constructor
+ public FolderTreeItemViewModel(
+ IFileSystem fileSystem, // Made non-nullable for the main constructor path
+ Func folderWatcherFactory, // Made non-nullable
+ IScheduler backgroundScheduler, // Made non-nullable
+ FolderTreeItemViewModel? parent = null)
{
- this.fileSystem = fileSystem ??= Locator.Current.GetService();
- this.backgroundScheduler = backgroundScheduler ??= RxApp.TaskpoolScheduler;
- this.folderWatcherFactory = folderWatcherFactory ??= () => Locator.Current.GetService();
- folderWatcher = folderWatcherFactory();
- folderWatcher?.DisposeWith(disposableRegistration);
-
- subFolders = new SourceList();
- subFolders.Connect()
- .Sort(SortExpressionComparer.Ascending(f => f.Path))
+ Parent = parent;
+ _fileSystem = fileSystem; // Direct assignment
+ _backgroundScheduler = backgroundScheduler;
+ _folderWatcherFactory = folderWatcherFactory;
+
+ _folderWatcher = _folderWatcherFactory.Invoke();
+ _folderWatcher?.DisposeWith(disposableRegistration);
+
+ _subFoldersSourceList = new SourceList().DisposeWith(disposableRegistration);
+ _subFoldersSourceList.Connect()
+ .Sort(Comparer.Create((a, b) =>
+ {
+ if (a.IsPlaceholder && !b.IsPlaceholder) return -1;
+ if (!a.IsPlaceholder && b.IsPlaceholder) return 1;
+ return string.Compare(a.Path, b.Path, StringComparison.OrdinalIgnoreCase);
+ }))
.ObserveOn(RxApp.MainThreadScheduler)
- .Bind(out _children)
+ .Bind(out _childrenBinding)
.Subscribe()
.DisposeWith(disposableRegistration);
- subFolders.DisposeWith(disposableRegistration);
-
- _folderName = this.WhenAnyValue(x => x.Path)
- .Select(p =>
- {
- var path = System.IO.Path.GetFileName(p);
-
- return string.IsNullOrEmpty(path) ? p : path; // on a disk path (e.g. C:\, Path.GetFileName() returns an empty string
- })
- .ToProperty(this, x => x.FolderName);
-
- this.WhenAnyValue(x => x.Path, x => x.IsVisible)
- .Where(x => x.Item2) // make sure the item is visible before loading
- .Select(x => x.Item1)
- .Where(p => p != null)
- .ObserveOn(backgroundScheduler)
- .Select(p =>
+ this.WhenAnyValue(x => x.Path)
+ .Where(p => !string.IsNullOrEmpty(p) && !_childrenLoaded && !IsPlaceholder)
+ .ObserveOn(_backgroundScheduler)
+ .Subscribe(async p =>
{
+ bool hasAnySubfolders = false;
try
{
- return fileSystem.GetSubFolders(p);
+ hasAnySubfolders = await Task.Run(() => _fileSystem.GetSubFolders(p!).Any());
}
-#pragma warning disable CA1031 // Do not catch general exception types
- catch
-#pragma warning restore CA1031 // Do not catch general exception types
+ catch (Exception ex)
{
- // If a sub folder cannot be accessed, then ignore it, no matter the reasons.
- // Otherwise, only lots and lots of crashes ensue, for reasons that could not be handled otherwise anyway.
- return null;
+ System.Diagnostics.Debug.WriteLine($"Error checking for subfolders in {p} for placeholder: {ex.Message}");
}
- })
- .Where(p => p != null)
- .Select(paths =>
- {
- return paths.Where(p => p != null)
- .Select(p =>
+ RxApp.MainThreadScheduler.Schedule(() =>
+ {
+ if (hasAnySubfolders && !_childrenLoaded && _subFoldersSourceList.Count == 0)
{
- try
- {
- return new FolderTreeItemViewModel(fileSystem, folderWatcherFactory, backgroundScheduler) { Path = p };
- }
- catch (UnauthorizedAccessException) { return null; }
- })
- .Where(f => f != null)
- .ToList();
+ _subFoldersSourceList.Add(new FolderTreeItemViewModel(_fileSystem, _folderWatcherFactory, _backgroundScheduler, this) { IsPlaceholder = true, Path = System.IO.Path.Combine(p!, "placeholder") });
+ }
+ });
})
- .Subscribe(folders => subFolders.AddRange(folders))
.DisposeWith(disposableRegistration);
- CreateFolder = ReactiveCommand.Create(name =>
+ _folderNameOaph = this.WhenAnyValue(x => x.Path)
+ .Select(p =>
{
- var newFolderPath = System.IO.Path.Combine(Path, name);
+ if (string.IsNullOrEmpty(p)) return string.Empty;
+ if (IsPlaceholder) return "(loading...)";
- if (Children.Select(f => f.Path).Any(s => s.PathEquals(newFolderPath))) return Unit.Default;
+ string name = System.IO.Path.GetFileName(p);
+ if (string.IsNullOrEmpty(name))
+ {
+ string tempPath = p.TrimEnd(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar);
+ if (string.IsNullOrEmpty(tempPath)) return p;
+ name = System.IO.Path.GetFileName(tempPath);
+ if (string.IsNullOrEmpty(name)) return p;
+ }
+ return name;
+ })
+ .ToProperty(this, x => x.FolderName, initialValue: string.Empty, scheduler: RxApp.MainThreadScheduler)
+ .DisposeWith(disposableRegistration);
- fileSystem.CreateFolder(newFolderPath);
+ this.WhenAnyValue(x => x.IsExpanded, x => x.Path)
+ .Where(x => x.Item1 && !_childrenLoaded && !string.IsNullOrEmpty(x.Item2) && !IsPlaceholder)
+ .ObserveOn(_backgroundScheduler)
+ .Select(x => x.Item2)
+ .Subscribe(async path => await LoadChildrenAsync(path!))
+ .DisposeWith(disposableRegistration);
- subFolders.Add(new FolderTreeItemViewModel(fileSystem, folderWatcherFactory, backgroundScheduler) { Path = newFolderPath });
+ if (_folderWatcher != null)
+ {
+ this.WhenAnyValue(x => x.Path)
+ .Where(p => !string.IsNullOrEmpty(p) && !IsPlaceholder)
+ .ObserveOn(RxApp.MainThreadScheduler)
+ .Subscribe(p =>
+ {
+ try
+ {
+ _folderWatcher.Path = p!;
+ _folderWatcher.IncludeSubdirectories = false;
+ _folderWatcher.EnableRaisingEvents = true;
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"Error setting up watcher for {p}: {ex.Message}");
+ }
+ })
+ .DisposeWith(disposableRegistration);
+
+ Observable.FromEventPattern(
+ h => _folderWatcher.Created += h, h => _folderWatcher.Created -= h)
+ .ObserveOn(RxApp.MainThreadScheduler)
+ .Subscribe(e => AddSubFolder(e.EventArgs.FullPath))
+ .DisposeWith(disposableRegistration);
+
+ Observable.FromEventPattern(
+ h => _folderWatcher.Deleted += h, h => _folderWatcher.Deleted -= h)
+ .ObserveOn(RxApp.MainThreadScheduler)
+ .Subscribe(e => RemoveSubFolder(e.EventArgs.FullPath))
+ .DisposeWith(disposableRegistration);
+
+ Observable.FromEventPattern(
+ h => _folderWatcher.Renamed += h, h => _folderWatcher.Renamed -= h)
+ .ObserveOn(RxApp.MainThreadScheduler)
+ .Subscribe(e => RenameSubFolder(e.EventArgs.OldFullPath, e.EventArgs.FullPath))
+ .DisposeWith(disposableRegistration);
+ }
- return Unit.Default;
- });
+ CreateFolderCommand = ReactiveCommand.Create(
+ (name) => CreateFolderInternal(name),
+ this.WhenAnyValue(x => x.Path).Select(p => !string.IsNullOrEmpty(p) && !IsPlaceholder)
+ ).DisposeWith(disposableRegistration);
+ }
- this.WhenAnyValue(x => x.Path)
- .Where(p => !string.IsNullOrEmpty(p))
- .Where(_ => folderWatcher != null)
- .Subscribe(p =>
+ // Constructor for XAML Designer / Service Locator fallback
+ public FolderTreeItemViewModel() : this(
+ Locator.Current.GetService() ?? new DesignTimeFileSystem(), // Use a specific design-time FS
+ () => Locator.Current.GetService() ?? new FileSystemWatcher(), // Provide a factory that can return a default
+ RxApp.MainThreadScheduler,
+ null)
+ {
+ IsVisible = true;
+ Path = @"C:\Design";
+ IsExpanded = true;
+
+ var child1 = new FolderTreeItemViewModel(this._fileSystem, this._folderWatcherFactory, this._backgroundScheduler, this) { Path = @"C:\Design\Child1", IsVisible = true };
+ var child2 = new FolderTreeItemViewModel(this._fileSystem, this._folderWatcherFactory, this._backgroundScheduler, this) { Path = @"C:\Design\Child2", IsVisible = true };
+ _subFoldersSourceList.AddRange(new[] { child1, child2 });
+ }
+
+ private async Task LoadChildrenAsync(string path)
+ {
+ if (IsPlaceholder || _childrenLoaded) return;
+
+ _subFoldersSourceList.Edit(update => update.Clear());
+
+ if (string.IsNullOrEmpty(path))
+ {
+ _childrenLoaded = true;
+ return;
+ }
+
+ try
+ {
+ var newSubFolders = (await Task.Run(() => _fileSystem.GetSubFolders(path).ToList()))
+ .Select(p => new FolderTreeItemViewModel(_fileSystem, _folderWatcherFactory, _backgroundScheduler, this) { Path = p, IsVisible = true });
+
+ _subFoldersSourceList.AddRange(newSubFolders);
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"Error loading children for {path}: {ex.Message}");
+ }
+ finally
+ {
+ _childrenLoaded = true;
+ }
+ }
+
+ private void AddSubFolder(string path)
+ {
+ RxApp.MainThreadScheduler.Schedule(() =>
+ {
+ if (_fileSystem.DirectoryExists(path) && !_subFoldersSourceList.Items.Any(f => !f.IsPlaceholder && f.Path.PathEquals(path)))
{
- folderWatcher.Path = p;
- folderWatcher.IncludeSubdirectories = false;
- folderWatcher.NotifyFilter = NotifyFilters.DirectoryName;
- try
+ if (_childrenLoaded)
{
- folderWatcher.EnableRaisingEvents = true;
+ var placeholder = _subFoldersSourceList.Items.FirstOrDefault(i => i.IsPlaceholder);
+ if (placeholder != null) _subFoldersSourceList.Remove(placeholder);
- folderWatcher.Created += OnFolderAdded;
- folderWatcher.Deleted += OnFolderDeleted;
- folderWatcher.Renamed += OnFolderRenamed;
- }
-#pragma warning disable CA1031 // Do not catch general exception types
- catch
-#pragma warning restore CA1031 // Do not catch general exception types
- {
- // FileSystemWatcher can throw all kinds of exceptions, which are completely irrelevant,
- // because if they happen, nothing can be done anyway
+ _subFoldersSourceList.Add(new FolderTreeItemViewModel(_fileSystem, _folderWatcherFactory, _backgroundScheduler, this) { Path = path, IsVisible = true });
}
- });
+ }
+ });
}
- private void OnFolderAdded(object sender, FileSystemEventArgs e)
+ private void RemoveSubFolder(string path)
{
RxApp.MainThreadScheduler.Schedule(() =>
{
- if (!subFolders.Items.Any(f => f.Path.PathEquals(e.FullPath)))
+ var existing = _subFoldersSourceList.Items.FirstOrDefault(f => !f.IsPlaceholder && f.Path.PathEquals(path));
+ if (existing != null)
{
- subFolders.Add(new FolderTreeItemViewModel(fileSystem, folderWatcherFactory, backgroundScheduler) { Path = e.FullPath });
+ _subFoldersSourceList.Remove(existing);
+ existing.Dispose();
+ }
+ if (!_subFoldersSourceList.Items.Any(i => !i.IsPlaceholder) && _childrenLoaded && !IsPlaceholder)
+ {
+ _backgroundScheduler.Schedule(async () => {
+ bool hasAnySubfolders = false;
+ try { hasAnySubfolders = await Task.Run(() => _fileSystem.GetSubFolders(this.Path).Any()); } catch { /* ignore */ }
+ if (hasAnySubfolders) {
+ RxApp.MainThreadScheduler.Schedule(() => {
+ if (!_subFoldersSourceList.Items.Any())
+ {
+ _subFoldersSourceList.Add(new FolderTreeItemViewModel(_fileSystem, _folderWatcherFactory, _backgroundScheduler, this) { IsPlaceholder = true, Path = System.IO.Path.Combine(this.Path, "placeholder") });
+ }
+ });
+ }
+ });
}
});
}
- private void OnFolderDeleted(object sender, FileSystemEventArgs e)
+ private void RenameSubFolder(string oldPath, string newPath)
{
RxApp.MainThreadScheduler.Schedule(() =>
{
- var item = subFolders.Items.FirstOrDefault(f => f.Path.PathEquals(e.FullPath));
-
- if (item != null) subFolders.Remove(item);
+ var existing = _subFoldersSourceList.Items.FirstOrDefault(f => !f.IsPlaceholder && f.Path.PathEquals(oldPath));
+ if (existing != null)
+ {
+ existing.Path = newPath;
+ }
+ else
+ {
+ if (_childrenLoaded) AddSubFolder(newPath);
+ }
});
}
- private void OnFolderRenamed(object sender, RenamedEventArgs e)
+ private FolderTreeItemViewModel? CreateFolderInternal(string name)
{
- RxApp.MainThreadScheduler.Schedule(() =>
+ if (string.IsNullOrWhiteSpace(name) || IsPlaceholder)
{
- var item = subFolders.Items.FirstOrDefault(f => f.Path.PathEquals(e.OldFullPath));
+ System.Diagnostics.Debug.WriteLine("Folder name cannot be empty or called on a placeholder.");
+ return null;
+ }
- if (item != null)
- {
- subFolders.Remove(item);
+ var newPath = System.IO.Path.Combine(Path, name);
+
+ if (_fileSystem.DirectoryExists(newPath))
+ {
+ return _subFoldersSourceList.Items.FirstOrDefault(c => !c.IsPlaceholder && c.Path.PathEquals(newPath));
+ }
- subFolders.Add(new FolderTreeItemViewModel(fileSystem, folderWatcherFactory, backgroundScheduler) { Path = e.FullPath });
+ try
+ {
+ System.IO.Directory.CreateDirectory(newPath);
+
+ var newFolderVM = new FolderTreeItemViewModel(_fileSystem, _folderWatcherFactory, _backgroundScheduler, this) { Path = newPath, IsVisible = true };
+ if (!_subFoldersSourceList.Items.Any(f => f.Path.PathEquals(newPath)))
+ {
+ _subFoldersSourceList.Add(newFolderVM);
}
- });
+ return newFolderVM;
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"Failed to create directory '{newPath}': {ex.Message}");
+ return null;
+ }
}
-
- ~FolderTreeItemViewModel()
+
+ public void AddChild(FolderTreeItemViewModel child)
{
- if (folderWatcher != null)
+ if (!_subFoldersSourceList.Items.Contains(child))
{
- folderWatcher.Created -= OnFolderAdded;
- folderWatcher.Deleted -= OnFolderDeleted;
- folderWatcher.Renamed -= OnFolderRenamed;
+ _subFoldersSourceList.Add(child);
}
+ }
+
+ public void ClearChildren()
+ {
+ _subFoldersSourceList.Clear();
+ }
+ public void Dispose()
+ {
disposableRegistration.Dispose();
+ foreach (var child in _subFoldersSourceList.Items.ToList())
+ {
+ child.Dispose();
+ }
+ _subFoldersSourceList.Clear();
}
-}
\ No newline at end of file
+}
+
+// Simple DesignTimeFileSystem to satisfy the constructor for the designer
+internal class DesignTimeFileSystem : IFileSystem
+{
+ public IEnumerable GetSubFolders(string path) { yield break; } // No subfolders in design time
+ public IEnumerable GetFiles(string folder) { yield break; } // No files
+ public bool IsFolderEmpty(string path) => true;
+ // FileExists, DirectoryExists, Move, CreateFolder use default interface implementations or are not critical for design view
+}
+
+#nullable disable
\ No newline at end of file
diff --git a/src/ImageSort/ViewModels/FoldersViewModel.cs b/src/ImageSort/ViewModels/FoldersViewModel.cs
index adb03de5..9c7b0cca 100644
--- a/src/ImageSort/ViewModels/FoldersViewModel.cs
+++ b/src/ImageSort/ViewModels/FoldersViewModel.cs
@@ -1,4 +1,5 @@
-using DynamicData;
+#nullable enable
+using DynamicData;
using DynamicData.Binding;
using ImageSort.FileSystem;
using ImageSort.Helpers;
@@ -7,50 +8,53 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
+using System.IO;
using System.Linq;
using System.Reactive;
using System.Reactive.Concurrency;
using System.Reactive.Linq;
+// Removed System.Windows.Input; as ICommand is part of ReactiveUI
namespace ImageSort.ViewModels;
public class FoldersViewModel : ReactiveObject
{
- private readonly IFileSystem fileSystem;
- private readonly IScheduler backgroundScheduler;
+ private readonly IFileSystem _fileSystem; // Renamed for consistency
+ private readonly IScheduler _backgroundScheduler; // Renamed for consistency
+ private readonly Func _folderWatcherFactory; // Added for FolderTreeItemViewModel
- private FolderTreeItemViewModel _currentFolder;
+ private FolderTreeItemViewModel? _currentFolder; // Made nullable
- public FolderTreeItemViewModel CurrentFolder
+ public FolderTreeItemViewModel? CurrentFolder // Made nullable
{
get => _currentFolder;
set => this.RaiseAndSetIfChanged(ref _currentFolder, value);
}
- private readonly SourceList pinnedFolders;
+ private readonly SourceList _pinnedFoldersSourceList; // Renamed
- private readonly ReadOnlyObservableCollection _pinnedFolders;
- public ReadOnlyObservableCollection PinnedFolders => _pinnedFolders;
+ private readonly ReadOnlyObservableCollection _pinnedFoldersBinding; // Renamed
+ public ReadOnlyObservableCollection PinnedFolders => _pinnedFoldersBinding;
- private readonly ObservableAsPropertyHelper> _allFoldersTracked;
- public IEnumerable AllFoldersTracked => _allFoldersTracked.Value;
+ private readonly ObservableAsPropertyHelper> _displayedFolderItemsOaph; // Renamed
+ public IReadOnlyList DisplayedFolderItems => _displayedFolderItemsOaph.Value;
- private FolderTreeItemViewModel _selected;
+ private readonly ObservableAsPropertyHelper> _allFoldersTrackedOaph; // Renamed
+ public IEnumerable AllFoldersTracked => _allFoldersTrackedOaph.Value;
- public FolderTreeItemViewModel Selected
+ private FolderTreeItemViewModel? _selected; // Made nullable
+
+ public FolderTreeItemViewModel? Selected // Made nullable
{
get => _selected;
set => this.RaiseAndSetIfChanged(ref _selected, value);
}
- ///
- /// Should prompt the user to select a folder.
- ///
- public Interaction SelectFolder { get; }
- = new Interaction();
+ public Interaction SelectFolder { get; } // Return type nullable
+ = new Interaction();
- public Interaction PromptForName { get; }
- = new Interaction();
+ public Interaction PromptForName { get; } // Return type nullable
+ = new Interaction();
public ReactiveCommand Pin { get; }
public ReactiveCommand PinSelected { get; }
@@ -61,132 +65,203 @@ public FolderTreeItemViewModel Selected
public ReactiveCommand CreateFolderUnderSelected { get; }
- public FoldersViewModel(IFileSystem fileSystem = null, IScheduler backgroundScheduler = null)
+ public ReactiveCommand SelectNextFolder { get; }
+ public ReactiveCommand SelectPreviousFolder { get; }
+ public ReactiveCommand ExpandSelected { get; }
+ public ReactiveCommand CollapseSelectedOrGoToParent { get; }
+ public ReactiveCommand SetSelectedFolderAsCurrentImplicitly { get; }
+ public ReactiveCommand PinCurrentFolder { get; }
+
+ public ReactiveCommand GoToParentFolderCommand { get; }
+ public ReactiveCommand OpenSelectedFolderCommand { get; }
+
+
+ public FoldersViewModel(IFileSystem? fileSystem = null, IScheduler? backgroundScheduler = null, Func? folderWatcherFactory = null)
{
- this.fileSystem = fileSystem ??= Locator.Current.GetService();
- this.backgroundScheduler = backgroundScheduler ??= RxApp.TaskpoolScheduler;
+ _fileSystem = fileSystem ?? Locator.Current.GetService() ?? throw new ArgumentNullException(nameof(fileSystem));
+ _backgroundScheduler = backgroundScheduler ?? RxApp.TaskpoolScheduler;
+ _folderWatcherFactory = folderWatcherFactory ?? (() => Locator.Current.GetService()!);
- pinnedFolders = new SourceList();
- pinnedFolders.Connect()
+
+ _pinnedFoldersSourceList = new SourceList();
+ _pinnedFoldersSourceList.Connect()
.ObserveOn(RxApp.MainThreadScheduler)
- .Bind(out _pinnedFolders)
+ .Bind(out _pinnedFoldersBinding)
.Subscribe();
- _allFoldersTracked = this.WhenAnyValue(vm => vm.CurrentFolder)
- .CombineLatest(pinnedFolders.Connect(), (c, p) => (c, pinnedFolders.Items))
- .Select(folders => new[] {folders.c}.Concat(folders.Items))
- .ToProperty(this, vm => vm.AllFoldersTracked);
+ var pinnedFoldersObservable = _pinnedFoldersSourceList.Connect().ToCollection().StartWith(new List());
+
+ _displayedFolderItemsOaph = this.WhenAnyValue(x => x.CurrentFolder)
+ .CombineLatest(pinnedFoldersObservable,
+ (current, pinned) => (Current: current, Pinned: pinned))
+ // StartWith the current values to ensure an initial emission
+ .StartWith((Current: this.CurrentFolder, Pinned: _pinnedFoldersSourceList.Items.ToList() as IReadOnlyCollection ?? new List()))
+ .Select(data =>
+ {
+ var list = new List();
+ if (data.Current != null)
+ {
+ list.Add(data.Current);
+ }
+ // Ensure pinned folders are distinct and not the current folder if current is already added
+ list.AddRange(data.Pinned.Where(pf => pf != null && (data.Current == null || !pf.Path.PathEquals(data.Current.Path))).DistinctBy(pf => pf.Path));
+ return (IReadOnlyList)list.AsReadOnly(); // Return as ReadOnly for safety
+ })
+ .ToProperty(this, vm => vm.DisplayedFolderItems, initialValue: new List().AsReadOnly(), scheduler: RxApp.MainThreadScheduler);
+
+
+ _allFoldersTrackedOaph = this.WhenAnyValue(vm => vm.CurrentFolder)
+ .CombineLatest(pinnedFoldersObservable, (c, pItems) => (Current: c, PinnedItems: pItems))
+ .Select(folders =>
+ {
+ var list = new List();
+ if (folders.Current != null) list.Add(folders.Current);
+ list.AddRange(folders.PinnedItems.Where(pf => pf != null));
+ return (IEnumerable)list.Distinct();
+ })
+ .ToProperty(this, vm => vm.AllFoldersTracked, initialValue: Enumerable.Empty(), scheduler: RxApp.MainThreadScheduler);
Pin = ReactiveCommand.CreateFromTask(async () =>
{
try
{
- var folderToPin = await SelectFolder.Handle(Unit.Default);
+ var folderToPinPath = await SelectFolder.Handle(Unit.Default);
- if (pinnedFolders.Items.Any(f => f.Path.PathEquals(folderToPin))) return;
+ if (string.IsNullOrEmpty(folderToPinPath) || _pinnedFoldersSourceList.Items.Any(f => f.Path.PathEquals(folderToPinPath))) return;
- pinnedFolders.Add(
- new FolderTreeItemViewModel(fileSystem, backgroundScheduler: backgroundScheduler)
+ _pinnedFoldersSourceList.Add(
+ new FolderTreeItemViewModel(_fileSystem, _folderWatcherFactory, _backgroundScheduler)
{
- Path = folderToPin
+ Path = folderToPinPath
});
}
- catch (UnhandledInteractionException)
- {
- // an exception is ignored, because it only means that the
- // user has canceled the dialog.
- }
+ catch (UnhandledInteractionException) { /* User cancelled */ }
});
- var canPinSelectedExecute = this.WhenAnyValue(x => x.Selected, x => x.PinnedFolders.Count, (s, _) => s)
- .Select(s => s != null && !PinnedFolders.Where(f => f != null)
- .Select(f => f.Path)
- .Contains(s.Path));
+ var canPinSelectedExecute = this.WhenAnyValue(x => x.Selected)
+ .Select(s => s != null && !_pinnedFoldersBinding.Any(f => f.Path.PathEquals(s.Path)));
PinSelected = ReactiveCommand.Create(() =>
{
- pinnedFolders.Add(Selected);
+ if (Selected != null && !_pinnedFoldersSourceList.Items.Contains(Selected)) // Ensure not already pinned
+ {
+ _pinnedFoldersSourceList.Add(Selected);
+ }
}, canPinSelectedExecute);
- var canUnpinSelectedExecute = this.WhenAnyValue(vm => vm.Selected, x => x.PinnedFolders.Count, (s, _) => s)
- .Select(s => s != null && PinnedFolders.Where(f => f != null)
- .Select(f => f.Path)
- .Contains(s.Path));
+ var canUnpinSelectedExecute = this.WhenAnyValue(vm => vm.Selected)
+ .Select(s => s != null && _pinnedFoldersBinding.Any(f => f.Path.PathEquals(s.Path)));
UnpinSelected = ReactiveCommand.Create(() =>
{
- var pinned = pinnedFolders.Items.FirstOrDefault(f => f.Path.PathEquals(Selected.Path));
-
- if (pinned != null) pinnedFolders.Remove(pinned);
+ if (Selected == null) return;
+ var pinned = _pinnedFoldersSourceList.Items.FirstOrDefault(f => f.Path.PathEquals(Selected.Path));
+ if (pinned != null) _pinnedFoldersSourceList.Remove(pinned);
}, canUnpinSelectedExecute);
var canMovePinnedFolderUp = this.WhenAnyValue(x => x.Selected)
- .CombineLatest(PinnedFolders.ToObservableChangeSet(), (f, _) => f)
- .Select(s => pinnedFolders.Items.Contains(s) && pinnedFolders.Items.IndexOf(s) > 0);
+ .Select(s => s != null && _pinnedFoldersSourceList.Items.Contains(s) && _pinnedFoldersSourceList.Items.IndexOf(s) > 0);
MoveSelectedPinnedFolderUp = ReactiveCommand.Create(() =>
{
- var pinnedIndex = pinnedFolders.Items.IndexOf(Selected);
-
- if (pinnedIndex > 0) pinnedFolders.Move(pinnedIndex, pinnedIndex - 1);
+ if (Selected == null) return;
+ var pinnedIndex = _pinnedFoldersSourceList.Items.IndexOf(Selected);
+ if (pinnedIndex > 0) _pinnedFoldersSourceList.Move(pinnedIndex, pinnedIndex - 1);
}, canMovePinnedFolderUp);
var canMovePinnedFolderDown = this.WhenAnyValue(x => x.Selected)
- .CombineLatest(PinnedFolders.ToObservableChangeSet(), (f, _) => f)
- .Select(s =>
- pinnedFolders.Items.Contains(s) && pinnedFolders.Items.IndexOf(s) < pinnedFolders.Count - 1);
+ .Select(s => s != null && _pinnedFoldersSourceList.Items.Contains(s) && _pinnedFoldersSourceList.Items.IndexOf(s) < _pinnedFoldersSourceList.Count - 1);
MoveSelectedPinnedFolderDown = ReactiveCommand.Create(() =>
{
- var pinnedIndex = pinnedFolders.Items.IndexOf(Selected);
-
- if (pinnedIndex < pinnedFolders.Count - 1) pinnedFolders.Move(pinnedIndex, pinnedIndex + 1);
+ if (Selected == null) return;
+ var pinnedIndex = _pinnedFoldersSourceList.Items.IndexOf(Selected);
+ if (pinnedIndex < _pinnedFoldersSourceList.Count - 1) _pinnedFoldersSourceList.Move(pinnedIndex, pinnedIndex + 1);
}, canMovePinnedFolderDown);
- // make many above queries work
- pinnedFolders.Add(null);
- pinnedFolders.RemoveAt(0);
-
CreateFolderUnderSelected = ReactiveCommand.CreateFromTask(async () =>
{
+ if (Selected == null) return;
var name = await PromptForName.Handle(Unit.Default);
+ if (string.IsNullOrEmpty(name)) return;
+ await Selected.CreateFolderCommand.Execute(name);
+ }, this.WhenAnyValue(x => x.Selected).Select(s => s != null && !s.IsPlaceholder)); // Can't create under placeholder
- if (string.IsNullOrEmpty(name)) return Unit.Default;
+ SelectNextFolder = ReactiveCommand.Create(() =>
+ {
+ if (AllFoldersTracked == null || !AllFoldersTracked.Any()) return;
+ var currentList = AllFoldersTracked.ToList();
+ var currentIndex = Selected != null ? currentList.IndexOf(Selected) : -1;
+ Selected = currentList.ElementAtOrDefault((currentIndex + 1) % currentList.Count);
+ });
- return await Selected.CreateFolder.Execute(name);
+ SelectPreviousFolder = ReactiveCommand.Create(() =>
+ {
+ if (AllFoldersTracked == null || !AllFoldersTracked.Any()) return;
+ var currentList = AllFoldersTracked.ToList();
+ var currentIndex = Selected != null ? currentList.IndexOf(Selected) : -1;
+ Selected = currentList.ElementAtOrDefault((currentIndex - 1 + currentList.Count) % currentList.Count);
});
- FolderTreeItemViewModel oldFolder = null;
+ ExpandSelected = ReactiveCommand.Create(() =>
+ {
+ if (Selected != null) Selected.IsExpanded = true;
+ }, this.WhenAnyValue(x => x.Selected).Select(s => s != null && !s.IsExpanded && !s.IsPlaceholder));
- this.WhenAnyValue(x => x.CurrentFolder)
- .Where(f => f != null)
- .Subscribe(f =>
+ CollapseSelectedOrGoToParent = ReactiveCommand.Create(() =>
+ {
+ if (Selected != null)
{
- if (oldFolder != null) oldFolder.IsCurrentFolder = false;
+ if (Selected.IsExpanded && Selected.Children.Any()) Selected.IsExpanded = false;
+ else if (Selected.Parent != null) Selected = Selected.Parent;
+ }
+ }, this.WhenAnyValue(x => x.Selected).Select(s => s != null && ((s.IsExpanded && s.Children.Any()) || s.Parent != null)));
+
+ SetSelectedFolderAsCurrentImplicitly = ReactiveCommand.Create(() =>
+ {
+ if (Selected != null && !Selected.IsPlaceholder) CurrentFolder = Selected;
+ }, this.WhenAnyValue(x => x.Selected).Select(s => s != null && s != CurrentFolder && !s.IsPlaceholder));
+
+ PinCurrentFolder = ReactiveCommand.Create(() =>
+ {
+ if (CurrentFolder != null && !_pinnedFoldersSourceList.Items.Contains(CurrentFolder))
+ {
+ _pinnedFoldersSourceList.Add(CurrentFolder);
+ }
+ }, this.WhenAnyValue(x => x.CurrentFolder).Select(cf => cf != null && !_pinnedFoldersSourceList.Items.Contains(cf)));
- f.IsCurrentFolder = true;
+ FolderTreeItemViewModel? oldFolder = null; // Nullable
+ this.WhenAnyValue(x => x.CurrentFolder)
+ .Subscribe(f => // f can be null
+ {
+ if (oldFolder != null) oldFolder.IsCurrentFolder = false;
+ if (f != null) f.IsCurrentFolder = true;
oldFolder = f;
});
+
+ GoToParentFolderCommand = ReactiveCommand.Create(() =>
+ {
+ if (CurrentFolder?.Parent != null) CurrentFolder = CurrentFolder.Parent;
+ }, this.WhenAnyValue(x => x.CurrentFolder).Select(cf => cf?.Parent != null));
+
+ OpenSelectedFolderCommand = ReactiveCommand.Create(() =>
+ {
+ if (Selected != null && !Selected.IsPlaceholder) CurrentFolder = Selected;
+ }, this.WhenAnyValue(x => x.Selected).Select(s => s != null && !s.IsPlaceholder));
}
- [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "There are many sorts of reasons why a folder cannot be accessed, but all of them can be ignored.")]
public void AddPinnedFoldersFromPaths(IEnumerable paths)
{
- pinnedFolders.AddRange(paths.Select(p =>
- {
- if (!fileSystem.DirectoryExists(p)) return null;
-
- try
- {
- return new FolderTreeItemViewModel(fileSystem, backgroundScheduler: backgroundScheduler) { Path = p };
- }
- catch { return null; }
- }).Where(f => f != null));
+ _pinnedFoldersSourceList.AddRange(paths
+ .Where(p => !string.IsNullOrEmpty(p) && _fileSystem.DirectoryExists(p))
+ .Select(p => new FolderTreeItemViewModel(_fileSystem, _folderWatcherFactory, _backgroundScheduler) { Path = p })
+ .Where(f => f != null && !_pinnedFoldersSourceList.Items.Any(existing => existing.Path.PathEquals(f.Path))) // Ensure not already pinned
+ .ToList()); // ToList to avoid issues with modifying collection while iterating (though AddRange might handle it)
}
~FoldersViewModel()
{
- pinnedFolders.Dispose();
+ _pinnedFoldersSourceList.Dispose();
}
}
\ No newline at end of file
diff --git a/src/ImageSort/ViewModels/ImagesViewModel.cs b/src/ImageSort/ViewModels/ImagesViewModel.cs
index ff7e2139..54349e39 100644
--- a/src/ImageSort/ViewModels/ImagesViewModel.cs
+++ b/src/ImageSort/ViewModels/ImagesViewModel.cs
@@ -13,6 +13,8 @@
using System.Reactive;
using System.Reactive.Concurrency;
using System.Reactive.Linq;
+using ImageSort.ViewModels.Metadata; // Added using
+using ImageSort.Actions; // Required for DeleteAction
namespace ImageSort.ViewModels;
@@ -20,6 +22,8 @@ public class ImagesViewModel : ReactiveObject
{
private static readonly string[] supportedTypes = new[] { ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".tif", ".ico", ".webp" };
private FileSystemWatcher folderWatcher;
+ private readonly IFileSystem fileSystem; // Store IFileSystem
+ private readonly IRecycleBin recycleBin; // Store IRecycleBin
private string _currentPath;
@@ -53,6 +57,13 @@ public string SearchTerm
set => this.RaiseAndSetIfChanged(ref _searchTerm, value);
}
+ private bool _isMetadataVisible = true; // Default to true or as per design
+ public bool IsMetadataVisible
+ {
+ get => _isMetadataVisible;
+ set => this.RaiseAndSetIfChanged(ref _isMetadataVisible, value);
+ }
+
public Interaction PromptForNewFileName { get; }
= new Interaction();
@@ -63,10 +74,25 @@ public string SearchTerm
public ReactiveCommand GoRight { get; }
public ReactiveCommand RenameImage { get; }
- public ImagesViewModel(IFileSystem fileSystem = null, Func folderWatcherFactory = null)
+ // Add new commands based on AppAction
+ public ReactiveCommand SelectNextImage { get; }
+ public ReactiveCommand SelectPreviousImage { get; }
+ public ReactiveCommand DeleteImageCommand { get; } // To avoid conflict if a DeleteImage method exists
+
+ public MetadataViewModel Metadata { get; } // Added Metadata property
+
+ public ImagesViewModel(IFileSystem fileSystem = null, Func folderWatcherFactory = null,
+ IMetadataExtractor metadataExtractor = null,
+ MetadataSectionViewModelFactory metadataSectionFactory = null,
+ IRecycleBin recycleBin = null) // Added IRecycleBin
{
- fileSystem ??= Locator.Current.GetService();
+ this.fileSystem = fileSystem ?? Locator.Current.GetService();
folderWatcherFactory ??= () => Locator.Current.GetService();
+ metadataExtractor ??= Locator.Current.GetService(); // Resolve IMetadataExtractor
+ metadataSectionFactory ??= Locator.Current.GetService(); // Resolve MetadataSectionViewModelFactory
+ this.recycleBin = recycleBin ?? Locator.Current.GetService(); // Resolve and store IRecycleBin
+
+ Metadata = new MetadataViewModel(metadataExtractor, this.fileSystem, metadataSectionFactory); // Initialize MetadataViewModel
images = new SourceList();
@@ -94,6 +120,16 @@ public ImagesViewModel(IFileSystem fileSystem = null, Func fo
.Select(i => Images.ElementAtOrDefault(i))
.ToProperty(this, x => x.SelectedImage);
+ // Update Metadata.ImagePath when SelectedImage changes
+ this.WhenAnyValue(x => x.SelectedImage)
+ .Subscribe(path =>
+ {
+ if (Metadata != null)
+ {
+ Metadata.ImagePath = path;
+ }
+ });
+
images.Connect()
.Subscribe(_ =>
{
@@ -119,6 +155,9 @@ public ImagesViewModel(IFileSystem fileSystem = null, Func fo
SelectedIndex++;
}, canGoRight);
+ SelectNextImage = GoRight; // Alias GoRight
+ SelectPreviousImage = GoLeft; // Alias GoLeft
+
var canRenameImage = this.WhenAnyValue(x => x.SelectedImage)
.Select(p => !string.IsNullOrEmpty(p));
@@ -159,6 +198,28 @@ await NotifyUserOfError.Handle(Text.RenameNewNameContainsIllegalCharacters
return null;
}, canRenameImage);
+ var canDeleteImage = this.WhenAnyValue(x => x.SelectedImage)
+ .Select(p => !string.IsNullOrEmpty(p));
+
+ DeleteImageCommand = ReactiveCommand.Create(() =>
+ {
+ if (string.IsNullOrEmpty(SelectedImage)) return null;
+
+ try
+ {
+ return new DeleteAction(SelectedImage, this.fileSystem, this.recycleBin,
+ path => images.Remove(path), // onAct: remove from UI
+ path => images.Add(path) // onRevert: add back to UI, list will re-sort
+ );
+ }
+ catch (Exception ex)
+ {
+ NotifyUserOfError.Handle(ex.Message).Subscribe();
+ return null;
+ }
+ }, canDeleteImage);
+
+
this.WhenAnyValue(x => x.CurrentFolder)
.Where(f => !string.IsNullOrEmpty(f))
.Subscribe(f =>
@@ -190,6 +251,21 @@ public void InsertImage(string image)
images.Add(image);
}
+ public void OnImageMoved(string oldPath, string newPath)
+ {
+ if (images.Items.Contains(oldPath))
+ {
+ images.Replace(oldPath, newPath);
+ }
+ else
+ {
+ // If the old path wasn't tracked (e.g., image moved from outside current folder into a subfolder)
+ // and the new path is in the current folder, add it.
+ // This logic might need refinement based on how external moves are handled.
+ // For now, primarily for moves initiated by the app.
+ }
+ }
+
private void OnImageCreated(object sender, FileSystemEventArgs e)
{
RxApp.MainThreadScheduler.Schedule(() =>
diff --git a/src/ImageSort/ViewModels/MainViewModel.cs b/src/ImageSort/ViewModels/MainViewModel.cs
index 35e86b7d..22484d0e 100644
--- a/src/ImageSort/ViewModels/MainViewModel.cs
+++ b/src/ImageSort/ViewModels/MainViewModel.cs
@@ -12,29 +12,9 @@ namespace ImageSort.ViewModels;
public class MainViewModel : ReactiveObject
{
- private ActionsViewModel actions;
-
- public ActionsViewModel Actions
- {
- get => actions;
- set => this.RaiseAndSetIfChanged(ref actions, value);
- }
-
- private FoldersViewModel _foldersViewModel;
-
- public FoldersViewModel Folders
- {
- get => _foldersViewModel;
- set => this.RaiseAndSetIfChanged(ref _foldersViewModel, value);
- }
-
- private ImagesViewModel _images;
-
- public ImagesViewModel Images
- {
- get => _images;
- set => this.RaiseAndSetIfChanged(ref _images, value);
- }
+ public ActionsViewModel Actions { get; }
+ public FoldersViewModel Folders { get; }
+ public ImagesViewModel Images { get; }
public Interaction PickFolder { get; } = new Interaction();
@@ -45,30 +25,29 @@ public ImagesViewModel Images
public ReactiveCommand DeleteImage { get; }
- public MainViewModel(IFileSystem fileSystem = null, IRecycleBin recycleBin = null, IScheduler backgroundScheduler = null)
+ public MainViewModel(FoldersViewModel foldersViewModel, ImagesViewModel imagesViewModel, ActionsViewModel actionsViewModel,
+ IFileSystem fileSystem = null, IRecycleBin recycleBin = null, IScheduler backgroundScheduler = null)
{
+ Folders = foldersViewModel ?? throw new ArgumentNullException(nameof(foldersViewModel));
+ Images = imagesViewModel ?? throw new ArgumentNullException(nameof(imagesViewModel));
+ Actions = actionsViewModel ?? throw new ArgumentNullException(nameof(actionsViewModel));
+
fileSystem ??= Locator.Current.GetService();
recycleBin ??= Locator.Current.GetService();
backgroundScheduler ??= RxApp.TaskpoolScheduler;
- this.WhenAnyValue(x => x.Images)
- .Where(i => i != null)
- .Subscribe(i =>
+ // This subscription ensures ImagesViewModel.CurrentFolder is updated when FoldersViewModel.CurrentFolder changes.
+ this.WhenAnyValue(x => x.Folders.CurrentFolder)
+ .Where(f => f != null)
+ .Select(f => f.Path)
+ .Subscribe(folderPath =>
{
- this.WhenAnyValue(x => x.Folders.CurrentFolder)
- .Where(f => f != null)
- .Select(f => f.Path)
- .Subscribe(f =>
- {
- i.CurrentFolder = f;
- });
+ if (Images != null) Images.CurrentFolder = folderPath;
});
- var canOpenCurrentlySelectedFolder = this.WhenAnyValue(x => x.Folders)
- .Where(f => f != null)
- .SelectMany(f => f.WhenAnyValue(x => x.Selected, x => x.CurrentFolder, (s, c) => new { Selected = s, CurrentFolder = c }))
- .Where(f => f != null)
- .Select(f => f.Selected != null && f.Selected != f.CurrentFolder);
+ var canOpenCurrentlySelectedFolder = this.WhenAnyValue(x => x.Folders.Selected, x => x.Folders.CurrentFolder,
+ (s, c) => s != null && c != null && s != c && s.Path != c.Path) // Added s.Path != c.Path for clarity
+ .DistinctUntilChanged();
OpenCurrentlySelectedFolder = ReactiveCommand.Create(() =>
{
@@ -79,15 +58,21 @@ public MainViewModel(IFileSystem fileSystem = null, IRecycleBin recycleBin = nul
{
try
{
- Folders.CurrentFolder = new FolderTreeItemViewModel(fileSystem, backgroundScheduler: backgroundScheduler) { Path = await PickFolder.Handle(Unit.Default) };
+ // The constructor for FolderTreeItemViewModel now requires a Func and a nullable FolderTreeItemViewModel parent.
+ // Providing null for the watcher factory and parent as this is a new root item.
+ Folders.CurrentFolder = new FolderTreeItemViewModel(fileSystem, () => null, backgroundScheduler, null) { Path = await PickFolder.Handle(Unit.Default) };
}
catch (UnhandledInteractionException) { }
});
- var canMoveImageToFolderExecute = this.WhenAnyValue(x => x.Folders, x => x.Images, (f, i) => new { Folders = f, Images = i })
- .Where(fi => fi.Folders != null && fi.Images != null)
- .SelectMany(_ => Folders.WhenAnyValue(x => x.Selected, x => x.CurrentFolder, (s, c) => s != null && c != null && s != c)
- .CombineLatest(Images.WhenAnyValue(x => x.SelectedImage), (f, s) => f && s != null));
+ var canMoveImageToFolderExecute = this.WhenAnyValue(x => x.Folders.Selected, x => x.Folders.CurrentFolder, x => x.Images.SelectedImage,
+ (folderSelected, currentFolder, imageSelected) =>
+ folderSelected != null &&
+ currentFolder != null &&
+ folderSelected != currentFolder &&
+ folderSelected.Path != currentFolder.Path && // ensure paths are different
+ !string.IsNullOrEmpty(imageSelected))
+ .DistinctUntilChanged();
MoveImageToFolder = ReactiveCommand.CreateFromTask(async () =>
{
@@ -102,10 +87,9 @@ public MainViewModel(IFileSystem fileSystem = null, IRecycleBin recycleBin = nul
else if (Images.Images.Any()) Images.SelectedIndex = 0;
}, canMoveImageToFolderExecute);
- var canDeleteImageExecute = this.WhenAnyValue(x => x.Images)
- .Where(i => i != null)
- .SelectMany(i => i.WhenAnyValue(x => x.SelectedImage))
- .Select(i => !string.IsNullOrEmpty(i));
+ var canDeleteImageExecute = this.WhenAnyValue(x => x.Images.SelectedImage)
+ .Select(i => !string.IsNullOrEmpty(i))
+ .DistinctUntilChanged();
DeleteImage = ReactiveCommand.CreateFromTask(async () =>
{
@@ -120,16 +104,16 @@ public MainViewModel(IFileSystem fileSystem = null, IRecycleBin recycleBin = nul
else if (Images.Images.Any()) Images.SelectedIndex = 0;
}, canDeleteImageExecute);
- this.WhenAnyValue(x => x.Folders, x => x.Actions)
- .Where(models => models.Item1 != null && models.Item2 != null)
- .SelectMany(_ => Folders.WhenAnyValue(x => x.CurrentFolder))
+ this.WhenAnyValue(x => x.Folders.CurrentFolder)
.Select(_ => Unit.Default)
+ .ObserveOn(RxApp.MainThreadScheduler) // Ensure clear happens on UI thread if it affects UI
.Subscribe(async _ => await Actions.Clear.Execute());
- this.WhenAnyValue(x => x.Images, x => x.Actions)
- .Where(i => i.Item1 != null && i.Item2 != null)
- .SelectMany(_ => Images.RenameImage)
+ // When a rename action is created by ImagesViewModel, execute it through ActionsViewModel
+ Images.RenameImage
.Where(a => a != null)
- .Subscribe(async a => await Actions.Execute.Execute(a));
+ .ObserveOn(RxApp.MainThreadScheduler) // Ensure execute happens on UI thread
+ .SelectMany(action => Actions.Execute.Execute(action))
+ .Subscribe();
}
}
\ No newline at end of file
diff --git a/version.json b/version.json
new file mode 100644
index 00000000..c4988d91
--- /dev/null
+++ b/version.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json",
+ "version": "3.0-alpha",
+ "publicReleaseRefSpec": [
+ "^refs/heads/main$",
+ "^refs/heads/release\\/.+$"
+ ],
+ "cloudBuild": {
+ "buildNumber": {
+ "enabled": true,
+ "versionIncrement": "buildId"
+ }
+ }
+}