From 664095da2d82e9268a395613d565fe5cdfde2f66 Mon Sep 17 00:00:00 2001 From: Matheos Mattsson Date: Sun, 26 Apr 2026 11:55:37 +0300 Subject: [PATCH 1/9] Update NuGet packages, implement new APIs and update to .NET 10 Not tested well yet, especially not on Windows. Linux does not seem to work too well as of yet (copy and drag and drop) --- MainWindow.axaml.cs | 32 ++++++++++++++++++-------------- SelectSight.csproj | 20 ++++++++++---------- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/MainWindow.axaml.cs b/MainWindow.axaml.cs index 9b2c3ce..31117e4 100644 --- a/MainWindow.axaml.cs +++ b/MainWindow.axaml.cs @@ -203,7 +203,7 @@ private void OnAllFilesListBoxPointerReleased(object? sender, PointerReleasedEve private async void OnAllFilesListBoxPointerMoved(object? sender, PointerEventArgs e) { - if (_pressedListBoxItem == null || !ReferenceEquals(e.Pointer.Captured, _pressedListBoxItem)) return; + if (_pressedListBoxItem is null || !ReferenceEquals(e.Pointer.Captured, _pressedListBoxItem)) return; // Calculate the distance moved var currentPosition = e.GetPosition(this); @@ -223,18 +223,20 @@ private async void OnAllFilesListBoxPointerMoved(object? sender, PointerEventArg if (!_selectedFiles.Contains(clickedFileItem)) _selectedFiles.Add(clickedFileItem); // Ensure the clicked file is selected var filePaths = _selectedFiles.Select(f => f.FullPath).ToList(); - if (filePaths.Count != 0) + if (filePaths.Count > 0 && _pendingPointerPressedEventArgs is not null) { var data = await CreateFilesDataObject(filePaths); - await DragDrop.DoDragDrop(e, data, DragDropEffects.Copy | DragDropEffects.Link); + await DragDrop.DoDragDropAsync(_pendingPointerPressedEventArgs, data, DragDropEffects.Copy | DragDropEffects.Link); } } // Reset state after drag/drop finishes _isDragging = false; _pressedListBoxItem = null; + _pendingPointerPressedEventArgs = null; } + private PointerPressedEventArgs? _pendingPointerPressedEventArgs; private void OnAllFilesListBoxClick(object? sender, PointerPressedEventArgs e) { if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return; @@ -244,6 +246,7 @@ private void OnAllFilesListBoxClick(object? sender, PointerPressedEventArgs e) e.Pointer.Capture(_pressedListBoxItem); _isDragging = false; e.Handled = true; + _pendingPointerPressedEventArgs = e; } #endregion @@ -267,7 +270,7 @@ private async void CopySelectedBtnClick(object? sender, RoutedEventArgs e) var filePaths = _selectedFiles.Select(f => f.FullPath); var dataObject = await CreateFilesDataObject(filePaths); - await topLevel.Clipboard.SetDataObjectAsync(dataObject); + await topLevel.Clipboard.SetDataAsync(dataObject); ShowFeedback($"{_selectedFiles.Count} {(_selectedFiles.Count == 1 ? "file was" : "files were")} copied to the clipboard", 3); } catch (Exception ex) @@ -307,25 +310,26 @@ private async void ClearSelectedBtnClick(object? sender, RoutedEventArgs e) #endregion - private async Task CreateFilesDataObject(IEnumerable filePaths) + private async Task CreateFilesDataObject(IEnumerable filePaths) { - var data = new DataObject(); - + var data = new DataTransfer(); if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { var topLevel = GetTopLevel(this); - if (topLevel == null) return data; - var storageFiles = new List(); + if (topLevel is null) return data; foreach (var filePath in filePaths) { var storageFile = await topLevel.StorageProvider.TryGetFileFromPathAsync(filePath); - if (storageFile is not null) storageFiles.Add(storageFile); + if (storageFile is null) continue; + data.Add(DataTransferItem.CreateFile(storageFile)); } - data.Set(DataFormats.Files, storageFiles); + return data; } - else - data.Set("text/uri-list", string.Join(Environment.NewLine, filePaths.Select(f => new Uri(f).AbsoluteUri))); - + + // Linux + var uriList = string.Join(Environment.NewLine, filePaths.Select(f => new Uri(f).AbsoluteUri)); + var uriListFormat = DataFormat.CreateStringPlatformFormat("text/uri-list"); + data.Add(DataTransferItem.Create(uriListFormat, uriList)); return data; } } \ No newline at end of file diff --git a/SelectSight.csproj b/SelectSight.csproj index 897078e..5ce854a 100644 --- a/SelectSight.csproj +++ b/SelectSight.csproj @@ -1,7 +1,7 @@  WinExe - net9.0 + net10.0 win-x64;linux-x64 enable true @@ -12,21 +12,21 @@ - - - - + + + + - + None All - - - - + + + + From 700c39f6c41d762a1fd86df983de000a8bb5f042 Mon Sep 17 00:00:00 2001 From: Matheos Mattsson Date: Sun, 26 Apr 2026 12:22:26 +0300 Subject: [PATCH 2/9] Update selection counter when selecting and deselecting.. --- MainWindow.axaml.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/MainWindow.axaml.cs b/MainWindow.axaml.cs index 31117e4..9d029e2 100644 --- a/MainWindow.axaml.cs +++ b/MainWindow.axaml.cs @@ -124,6 +124,7 @@ void SelectedFilesOnCollectionChanged(object? sender, NotifyCollectionChangedEve { File.WriteAllLines(selectedFilesFile, _selectedFiles.Select(f => f.FullPath)); RefreshUiButtonStates(); // Ensure the UI reflects the current state of selected files + RefreshFilesInfoText(); } void AllFilesOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) => RefreshFilesInfoText(); } @@ -303,6 +304,7 @@ private async void ClearSelectedBtnClick(object? sender, RoutedEventArgs e) if (await box.ShowAsync() != ButtonResult.Yes) return; _selectedFiles.Clear(); + RefreshFilesInfoText(); ShowFeedback("Cleared all selected files", 3); } From eaaa82cbfa8d2d7cac138d07b47ce6c26e2e526f Mon Sep 17 00:00:00 2001 From: Matheos Mattsson Date: Sun, 26 Apr 2026 15:03:40 +0300 Subject: [PATCH 3/9] Implement "Sort by" functionality For now you can only set the mode before opening a folder (while picker open) or after all thumbnails have fully loaded. Changing the sort mode during load is not supported and the picker is disabled while loading. TBD --- FileItem.cs | 13 ++-- MainWindow.axaml | 18 ++++- MainWindow.axaml.cs | 182 +++++++++++++++++++++++++++++++++----------- 3 files changed, 157 insertions(+), 56 deletions(-) diff --git a/FileItem.cs b/FileItem.cs index dae6260..ea9bbc8 100644 --- a/FileItem.cs +++ b/FileItem.cs @@ -1,8 +1,3 @@ -using System.Linq; -using MetadataExtractor; -using MetadataExtractor.Formats.Exif; -using SkiaSharp; - namespace SelectSight; using System; @@ -13,10 +8,11 @@ namespace SelectSight; using System.Threading.Tasks; using Avalonia.Media.Imaging; -public class FileItem(string fullPath) : INotifyPropertyChanged +public class FileItem(FileInfo fileInfo) : INotifyPropertyChanged { - public string FullPath { get; } = fullPath; - public string Name { get; } = Path.GetFileName(fullPath); + public string FullPath { get; } = fileInfo.FullName; + public string Name { get; } = fileInfo.Name; + public DateTime ModifiedDate { get; } = fileInfo.LastWriteTime; private Bitmap? _thumbnail; public Bitmap? Thumbnail @@ -27,6 +23,7 @@ public Bitmap? Thumbnail public async Task LoadThumbnailAsync() { + if (Thumbnail is not null) return; try { var extension = Path.GetExtension(FullPath).ToLowerInvariant(); diff --git a/MainWindow.axaml b/MainWindow.axaml index fb1c0bb..37093c1 100644 --- a/MainWindow.axaml +++ b/MainWindow.axaml @@ -8,8 +8,20 @@ Title="SelectSight" Icon="avares://SelectSight/Assets/icon.ico"> - - + + + + + + Date Modified (oldest first) + Date Modified (newest first) + Name (A-Z) + Name (Z-A) + + + + - diff --git a/MainWindow.axaml.cs b/MainWindow.axaml.cs index 9d029e2..dda8e6a 100644 --- a/MainWindow.axaml.cs +++ b/MainWindow.axaml.cs @@ -23,6 +23,8 @@ namespace SelectSight; public partial class MainWindow : Window { + private enum Sorting { DateOldestFirst, DateNewestFirst, NameAscending, NameDescending }; + private readonly ObservableCollection _allFiles = []; private readonly ObservableCollection _selectedFiles = []; @@ -31,6 +33,21 @@ public partial class MainWindow : Window private const double DragThreshold = 5.0; private ListBoxItem? _pressedListBoxItem; + private Sorting _sortBy = Sorting.DateOldestFirst; + private IStorageFolder? _currentFolder; + + private readonly Lazy _selectedFilesFileLazy = new(() => + { + const string selectSightTempFolder = "SelectSightData"; + const string selectSightSelectedFilesFile = "SelectedFiles.ss"; + + var selectSightTemp = Path.Combine(Path.GetTempPath(), selectSightTempFolder); + if (!Directory.Exists(selectSightTemp)) Directory.CreateDirectory(selectSightTemp); + return Path.Combine(selectSightTemp, selectSightSelectedFilesFile); + }); + + private string SelectedFilesFile => _selectedFilesFileLazy.Value; + public MainWindow() { InitializeComponent(); @@ -66,6 +83,9 @@ private async Task Initialize() return; } + // Setup sort by listener before choosing folder so it can be changed before first load + SetupSortByListener(); + var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { Title = "Select a folder", @@ -78,55 +98,12 @@ await Task.Run(async () => { if (folders.Count > 0) { - var directoryPath = folders[0].Path.LocalPath; - if (!Directory.Exists(directoryPath)) - { - Console.WriteLine($"Directory not found: {directoryPath}"); - return; - } - - const string selectSightTempFolder = "SelectSightData"; - const string selectSightSelectedFilesFile = "SelectedFiles.ss"; - - var selectSightTemp = Path.Combine(Path.GetTempPath(), selectSightTempFolder); - if (!Directory.Exists(selectSightTemp)) Directory.CreateDirectory(selectSightTemp); - var selectedFilesFile = Path.Combine(selectSightTemp, selectSightSelectedFilesFile); - - var oldSelections = File.Exists(selectedFilesFile) - ? (await File.ReadAllLinesAsync(selectedFilesFile)).ToHashSet() - : []; - + // Setup Listbox collection listeners _selectedFiles.CollectionChanged += SelectedFilesOnCollectionChanged; _allFiles.CollectionChanged += AllFilesOnCollectionChanged; - var directoryInfo = new DirectoryInfo(directoryPath); - var files = directoryInfo.GetFiles().OrderBy(p => p.CreationTime).ToArray(); - var totalFiles = files.Length; - var currentFileIndex = 0; - foreach (var fileInfo in files) - { - var filePath = fileInfo.FullName; - var fileItem = new FileItem(filePath); - _allFiles.Add(fileItem); - - // If the file was previously selected, add it to the selected files - // (Queue on UI Thread to avoid issues with collection modification due to modification above that also affects AllFilesListBox) - if (oldSelections.Contains(filePath)) Dispatcher.UIThread.Post(() => _selectedFiles.Add(fileItem)); - - await fileItem.LoadThumbnailAsync(); - currentFileIndex++; - ShowFeedback($"Loading files and creating thumbnails {currentFileIndex/(float)totalFiles:P} ({currentFileIndex}/{totalFiles})", -1, false); - } - ShowFeedback("Files loaded successfully", 4, false); + await LoadFiles(folders[0]); return; - - void SelectedFilesOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) - { - File.WriteAllLines(selectedFilesFile, _selectedFiles.Select(f => f.FullPath)); - RefreshUiButtonStates(); // Ensure the UI reflects the current state of selected files - RefreshFilesInfoText(); - } - void AllFilesOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) => RefreshFilesInfoText(); } Dispatcher.UIThread.Post(() => @@ -134,8 +111,123 @@ void SelectedFilesOnCollectionChanged(object? sender, NotifyCollectionChangedEve if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) desktop.Shutdown(); }); + + return; + + void SelectedFilesOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + File.WriteAllLines(SelectedFilesFile, _selectedFiles.Select(f => f.FullPath)); + RefreshUiButtonStates(); // Ensure the UI reflects the current state of selected files + RefreshFilesInfoText(); + } + void AllFilesOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) => RefreshFilesInfoText(); }); } + + private Task ReloadFiles() => LoadFiles(_currentFolder); + + private async Task LoadFiles(IStorageFolder? folder) + { + if (folder is null) + { + Console.WriteLine("No folder selected."); + return; + } + var directoryPath = folder.Path.LocalPath; + if (!Directory.Exists(directoryPath)) + { + Console.WriteLine($"Directory not found: {directoryPath}"); + return; + } + + _currentFolder = folder; + + var oldSelections = File.Exists(SelectedFilesFile) + ? (await File.ReadAllLinesAsync(SelectedFilesFile)).ToHashSet() + : []; + + var directoryInfo = new DirectoryInfo(directoryPath); + IEnumerable files = directoryInfo.GetFiles(); + switch (_sortBy) + { + + case Sorting.DateNewestFirst: + files = files.OrderByDescending(f => f.LastWriteTime); + break; + case Sorting.NameAscending: + files = files.OrderBy(f => f.Name); + break; + case Sorting.NameDescending: + files = files.OrderByDescending(f => f.Name); + break; + case Sorting.DateOldestFirst: + default: + files = files.OrderBy(f => f.LastWriteTime); + break; + } + var sortedFilesArray = files.ToArray(); + var totalFiles = sortedFilesArray.Length; + var currentFileIndex = 0; + var existingFileItems = _allFiles.ToDictionary(f => f.Name); // Lookup to reuse existing FileItems in new order + _allFiles.Clear(); + Dispatcher.UIThread.Post(() => SortByComboBox.IsEnabled = false); // For now we don't support changing sorting during load + foreach (var fileInfo in sortedFilesArray) + { + var fileItem = existingFileItems.TryGetValue(fileInfo.Name, out var found) ? found : new FileItem(fileInfo); + _allFiles.Add(fileItem); + + // If the file was previously selected, add it to the selected files + // (Queue on UI Thread to avoid issues with collection modification due to modification above that also affects AllFilesListBox) + if (oldSelections.Contains(fileInfo.FullName)) Dispatcher.UIThread.Post(() => _selectedFiles.Add(fileItem)); + + await fileItem.LoadThumbnailAsync(); + currentFileIndex++; + ShowFeedback($"Loading files and creating thumbnails {currentFileIndex/(float)totalFiles:P1} ({currentFileIndex}/{totalFiles})", -1, false); + } + Dispatcher.UIThread.Post(() => SortByComboBox.IsEnabled = true); // Re enable sorting + ShowFeedback("Files loaded successfully", 4, false); + } + + private void SetupSortByListener() + { + SortByComboBox.SelectionChanged += (sender, _) => + { + if (sender is not ComboBox { SelectedItem: ComboBoxItem selectedItem } || selectedItem.Tag?.ToString() is not { } sortMode) return; + var newSortBy = sortMode switch + { + "DateOldestFirst" => Sorting.DateOldestFirst, + "DateNewestFirst" => Sorting.DateNewestFirst, + "NameAsc" => Sorting.NameAscending, + "NameDesc" => Sorting.NameDescending, + _ => throw new ArgumentOutOfRangeException() + }; + if (newSortBy == _sortBy) return; + + // Sort the current files according to new order + _sortBy = newSortBy; + var orderedFiles = _allFiles.AsEnumerable(); + switch (_sortBy) + { + + case Sorting.DateNewestFirst: + orderedFiles = orderedFiles.OrderByDescending(f => f.ModifiedDate); + break; + case Sorting.NameAscending: + orderedFiles = orderedFiles.OrderBy(f => f.Name); + break; + case Sorting.NameDescending: + orderedFiles = orderedFiles.OrderByDescending(f => f.Name); + break; + case Sorting.DateOldestFirst: + default: + orderedFiles = orderedFiles.OrderBy(f => f.ModifiedDate); + break; + } + var orderedFilesArray = orderedFiles.ToArray(); + _allFiles.Clear(); + foreach (var fileItem in orderedFilesArray) _allFiles.Add(fileItem); + }; + } private void SetupDragAndDrop() { From 7f214b050e8b93889cad4a37f5cad3e00558931f Mon Sep 17 00:00:00 2001 From: Matheos Mattsson Date: Sun, 26 Apr 2026 15:09:35 +0300 Subject: [PATCH 4/9] get builds on all branches, updated to .net 10 --- .github/workflows/build-and-publish.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index afe28e1..06524a1 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -2,8 +2,6 @@ name: Build and Publish Cross-Platform on: push: - branches: - - main workflow_dispatch: {} # Allows manual triggering of the workflow permissions: @@ -33,7 +31,7 @@ jobs: - name: Setup .NET SDK uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: '10.0.x' - name: Restore Dependencies run: dotnet restore @@ -43,6 +41,10 @@ jobs: shell: bash run: | VERSION_NUMBER="${{ vars.MAJOR_VERSION }}.${{ vars.MINOR_VERSION }}.${{ github.run_number }}" + # Check if the current branch is NOT 'main' + if [ "${{ github.ref_name }}" != "main" ]; then + VERSION_NUMBER="${VERSION_NUMBER}-dev" # All other branches than main get -dev suffix + fi echo "Generated version: $VERSION_NUMBER" echo "generated_version=$VERSION_NUMBER" >> "$GITHUB_OUTPUT" From 339386b9e84f1b098f5a34725e128b2e3e382968 Mon Sep 17 00:00:00 2001 From: Matheos Mattsson Date: Sun, 26 Apr 2026 15:13:59 +0300 Subject: [PATCH 5/9] Bump action versions --- .github/workflows/build-and-publish.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index 06524a1..863072c 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -26,10 +26,10 @@ jobs: steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v6.0.2 - name: Setup .NET SDK - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5.2.0 with: dotnet-version: '10.0.x' @@ -69,7 +69,7 @@ jobs: echo "archive_name=$ARCHIVE_NAME" >> "$GITHUB_ENV" - name: Upload Archived Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.1 with: name: ${{ env.archive_name }} path: ./${{ env.archive_name }} @@ -84,15 +84,15 @@ jobs: steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v6.0.2 - name: Download all build artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8.0.1 with: path: ./release_assets - name: Create Release - uses: softprops/action-gh-release@v2 # Action to create a GitHub Release + uses: softprops/action-gh-release@v3.0.0 # Action to create a GitHub Release with: tag_name: v${{ needs.build.outputs.app_version }} name: Release v${{ needs.build.outputs.app_version }} From 6a6f7f72b00750d21b1e3774c21d0dfa189f45ee Mon Sep 17 00:00:00 2001 From: Matheos Mattsson Date: Sun, 7 Jun 2026 11:52:36 +0300 Subject: [PATCH 6/9] Improve data file persistence The files are now stored in documents under SelectSight/Data/ and the file name is determined based on the path to the open folder, meaning it can remember multiple previously opened folders and their selections --- MainWindow.axaml.cs | 39 +++++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/MainWindow.axaml.cs b/MainWindow.axaml.cs index dda8e6a..876604b 100644 --- a/MainWindow.axaml.cs +++ b/MainWindow.axaml.cs @@ -36,17 +36,10 @@ private enum Sorting { DateOldestFirst, DateNewestFirst, NameAscending, NameDesc private Sorting _sortBy = Sorting.DateOldestFirst; private IStorageFolder? _currentFolder; - private readonly Lazy _selectedFilesFileLazy = new(() => - { - const string selectSightTempFolder = "SelectSightData"; - const string selectSightSelectedFilesFile = "SelectedFiles.ss"; - - var selectSightTemp = Path.Combine(Path.GetTempPath(), selectSightTempFolder); - if (!Directory.Exists(selectSightTemp)) Directory.CreateDirectory(selectSightTemp); - return Path.Combine(selectSightTemp, selectSightSelectedFilesFile); - }); + private string? _selectSightDataFile; + private string SelectedFilesFile + => _selectSightDataFile ?? throw new InvalidOperationException("No folder is currently open."); - private string SelectedFilesFile => _selectedFilesFileLazy.Value; public MainWindow() { @@ -116,6 +109,7 @@ await Task.Run(async () => void SelectedFilesOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { + File.WriteAllLines(SelectedFilesFile, _selectedFiles.Select(f => f.FullPath)); RefreshUiButtonStates(); // Ensure the UI reflects the current state of selected files RefreshFilesInfoText(); @@ -141,6 +135,7 @@ private async Task LoadFiles(IStorageFolder? folder) } _currentFolder = folder; + SetDataFilePath(folder); var oldSelections = File.Exists(SelectedFilesFile) ? (await File.ReadAllLinesAsync(SelectedFilesFile)).ToHashSet() @@ -403,6 +398,30 @@ private async void ClearSelectedBtnClick(object? sender, RoutedEventArgs e) #endregion #endregion + + private void SetDataFilePath(IStorageFolder openFolder) + { + const string selectSightFolderName = "SelectSight"; + const string dataFolderName = "Data"; + var documentsFolder = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); + var selectSightDataFolder = Path.Combine(documentsFolder, selectSightFolderName, dataFolderName); + if (!Directory.Exists(selectSightDataFolder)) Directory.CreateDirectory(selectSightDataFolder); + if (openFolder is null) throw new InvalidOperationException(); + var localPath = openFolder.Path.LocalPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + localPath = OperatingSystem.IsWindows() ? localPath.ToLowerInvariant() : localPath; + var invalidChars = Path.GetInvalidPathChars().ToHashSet(); + var fileNameBuilder = new StringBuilder(); + foreach (var ch in localPath) + { + if (invalidChars.Contains(ch) || ch == Path.DirectorySeparatorChar || + ch == Path.AltDirectorySeparatorChar || ch == Path.VolumeSeparatorChar) + fileNameBuilder.Append('_'); + else fileNameBuilder.Append(ch); + } + var fileName = fileNameBuilder.ToString(); + if (fileName.Length > 237) fileName = fileName[..237]; + _selectSightDataFile = Path.Combine(selectSightDataFolder, $"{fileName}.ss"); + } private async Task CreateFilesDataObject(IEnumerable filePaths) { From c01fec416fd011a23a5f7ec3b8633496fa4f8772 Mon Sep 17 00:00:00 2001 From: Matheos Mattsson Date: Sun, 7 Jun 2026 12:22:17 +0300 Subject: [PATCH 7/9] Fix: loading files would cause event handlers fire for each old selection By loading the files before setting up event handlers, we prevent this. --- MainWindow.axaml.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/MainWindow.axaml.cs b/MainWindow.axaml.cs index 876604b..597cf48 100644 --- a/MainWindow.axaml.cs +++ b/MainWindow.axaml.cs @@ -91,11 +91,14 @@ await Task.Run(async () => { if (folders.Count > 0) { + // Load files first, to not fire event handler for each programmatic selection (if any) + await LoadFiles(folders[0]); + RefreshFilesInfoText(); + RefreshUiButtonStates(); + // Setup Listbox collection listeners _selectedFiles.CollectionChanged += SelectedFilesOnCollectionChanged; _allFiles.CollectionChanged += AllFilesOnCollectionChanged; - - await LoadFiles(folders[0]); return; } @@ -109,7 +112,6 @@ await Task.Run(async () => void SelectedFilesOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { - File.WriteAllLines(SelectedFilesFile, _selectedFiles.Select(f => f.FullPath)); RefreshUiButtonStates(); // Ensure the UI reflects the current state of selected files RefreshFilesInfoText(); From c43a46376833f9b63e2f0ff6a3ab404f05992665 Mon Sep 17 00:00:00 2001 From: Matheos Mattsson Date: Sun, 7 Jun 2026 13:11:43 +0300 Subject: [PATCH 8/9] Backup system for selection files Create a backup of previously opened folder when the same folder is opened the next time. The files are saved in the same location with .bak extension. Startup also initiates a cleanup which removes .bak files older than 14 days --- MainWindow.axaml.cs | 47 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/MainWindow.axaml.cs b/MainWindow.axaml.cs index 597cf48..dc8276e 100644 --- a/MainWindow.axaml.cs +++ b/MainWindow.axaml.cs @@ -37,7 +37,7 @@ private enum Sorting { DateOldestFirst, DateNewestFirst, NameAscending, NameDesc private IStorageFolder? _currentFolder; private string? _selectSightDataFile; - private string SelectedFilesFile + private string SelectSightDataFile => _selectSightDataFile ?? throw new InvalidOperationException("No folder is currently open."); @@ -112,7 +112,7 @@ await Task.Run(async () => void SelectedFilesOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { - File.WriteAllLines(SelectedFilesFile, _selectedFiles.Select(f => f.FullPath)); + File.WriteAllLines(SelectSightDataFile, _selectedFiles.Select(f => f.FullPath)); RefreshUiButtonStates(); // Ensure the UI reflects the current state of selected files RefreshFilesInfoText(); } @@ -138,10 +138,20 @@ private async Task LoadFiles(IStorageFolder? folder) _currentFolder = folder; SetDataFilePath(folder); - - var oldSelections = File.Exists(SelectedFilesFile) - ? (await File.ReadAllLinesAsync(SelectedFilesFile)).ToHashSet() - : []; + CleanupBackups(); // relies on SetDataFileFolder being called (to get the data folder path) + + HashSet oldSelections = []; + if (File.Exists(SelectSightDataFile)) + { + // Make a backup of previous data file + var fileInfo = new FileInfo(SelectSightDataFile); + var backupFileName = $"{SelectSightDataFile[..^3]}_{fileInfo.CreationTime:ddMMyyyy_HHmmss}.ss.bak"; + // If such a file already exists, assume it is correct (even matching seconds!) + if (!File.Exists(backupFileName)) File.Copy(SelectSightDataFile, backupFileName); + + // Set initial selections based on data file + oldSelections = (await File.ReadAllLinesAsync(SelectSightDataFile)).ToHashSet(); + } var directoryInfo = new DirectoryInfo(directoryPath); IEnumerable files = directoryInfo.GetFiles(); @@ -401,6 +411,31 @@ private async void ClearSelectedBtnClick(object? sender, RoutedEventArgs e) #endregion + // Clean up all backup files older than 14 days + private void CleanupBackups() + { + if (Path.GetDirectoryName(SelectSightDataFile) is not { } dataDirPath + || new DirectoryInfo(dataDirPath) is not { Exists: true } directoryInfo) + return; + + var cutOffTime = DateTime.UtcNow.Subtract(TimeSpan.FromDays(14)); + var bakFilesToDelete = directoryInfo + .GetFiles("*.ss.bak", SearchOption.TopDirectoryOnly) + .Where(f => f.LastWriteTimeUtc <= cutOffTime).ToArray(); + + foreach (var bakFile in bakFilesToDelete) + { + try + { + bakFile.Delete(); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Could not delete backup {bakFile.Name}: {ex.Message}"); + } + } + } + private void SetDataFilePath(IStorageFolder openFolder) { const string selectSightFolderName = "SelectSight"; From d7bc0fea97046b166ed5337bc4862488688a9670 Mon Sep 17 00:00:00 2001 From: Matheos Mattsson Date: Thu, 18 Jun 2026 00:09:47 +0300 Subject: [PATCH 9/9] Robustify selections and writing to file while loading We now keep a separate hashset of all full paths of selected files. This is populated with "old selection data" (if any) right after a folder has been opened and does not gradually populate as files load. This ensures the set is instantly up to date with real selections. New selections can be safely added during load without overwriting the old ones, old ones appear in UI when they are loaded. --- MainWindow.axaml.cs | 44 +++++++++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/MainWindow.axaml.cs b/MainWindow.axaml.cs index dc8276e..4be4478 100644 --- a/MainWindow.axaml.cs +++ b/MainWindow.axaml.cs @@ -28,6 +28,10 @@ private enum Sorting { DateOldestFirst, DateNewestFirst, NameAscending, NameDesc private readonly ObservableCollection _allFiles = []; private readonly ObservableCollection _selectedFiles = []; + // Full paths to selected files - Needed as this is populated from previous session at startup, before all files have been read in. + // acts as the source of truth for what to write to file for backup. + private HashSet _selectedFilesPaths = []; // Consider making this a ConcurrentDictionary... + private Point _dragStartPosition; private bool _isDragging; private const double DragThreshold = 5.0; @@ -91,14 +95,10 @@ await Task.Run(async () => { if (folders.Count > 0) { - // Load files first, to not fire event handler for each programmatic selection (if any) - await LoadFiles(folders[0]); - RefreshFilesInfoText(); - RefreshUiButtonStates(); - // Setup Listbox collection listeners _selectedFiles.CollectionChanged += SelectedFilesOnCollectionChanged; _allFiles.CollectionChanged += AllFilesOnCollectionChanged; + await LoadFiles(folders[0]); return; } @@ -112,7 +112,6 @@ await Task.Run(async () => void SelectedFilesOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { - File.WriteAllLines(SelectSightDataFile, _selectedFiles.Select(f => f.FullPath)); RefreshUiButtonStates(); // Ensure the UI reflects the current state of selected files RefreshFilesInfoText(); } @@ -140,7 +139,6 @@ private async Task LoadFiles(IStorageFolder? folder) SetDataFilePath(folder); CleanupBackups(); // relies on SetDataFileFolder being called (to get the data folder path) - HashSet oldSelections = []; if (File.Exists(SelectSightDataFile)) { // Make a backup of previous data file @@ -150,7 +148,7 @@ private async Task LoadFiles(IStorageFolder? folder) if (!File.Exists(backupFileName)) File.Copy(SelectSightDataFile, backupFileName); // Set initial selections based on data file - oldSelections = (await File.ReadAllLinesAsync(SelectSightDataFile)).ToHashSet(); + _selectedFilesPaths = (await File.ReadAllLinesAsync(SelectSightDataFile)).ToHashSet(); } var directoryInfo = new DirectoryInfo(directoryPath); @@ -185,7 +183,7 @@ private async Task LoadFiles(IStorageFolder? folder) // If the file was previously selected, add it to the selected files // (Queue on UI Thread to avoid issues with collection modification due to modification above that also affects AllFilesListBox) - if (oldSelections.Contains(fileInfo.FullName)) Dispatcher.UIThread.Post(() => _selectedFiles.Add(fileItem)); + if (_selectedFilesPaths.Contains(fileInfo.FullName)) Dispatcher.UIThread.Post(() => _selectedFiles.Add(fileItem)); await fileItem.LoadThumbnailAsync(); currentFileIndex++; @@ -247,7 +245,16 @@ private void SetupDragAndDrop() private void ToggleFileSelection(FileItem fileItem) { - if (!_selectedFiles.Remove(fileItem)) _selectedFiles.Add(fileItem); + if (_selectedFiles.Remove(fileItem)) + _selectedFilesPaths.Remove(fileItem.FullPath); + else + { + _selectedFilesPaths.Add(fileItem.FullPath); + _selectedFiles.Add(fileItem); + } + + // Update data file with new set of selections + File.WriteAllLines(SelectSightDataFile, _selectedFilesPaths); } private void ShowFeedback(string message, long durationSeconds = -1, bool resetTextAfterTimeout = true) => Task.Run(async () => @@ -320,7 +327,12 @@ private async void OnAllFilesListBoxPointerMoved(object? sender, PointerEventArg if (_pressedListBoxItem.DataContext is FileItem clickedFileItem) { - if (!_selectedFiles.Contains(clickedFileItem)) _selectedFiles.Add(clickedFileItem); // Ensure the clicked file is selected + if (!_selectedFiles.Contains(clickedFileItem)) + { + // Ensure the clicked file is selected + _selectedFiles.Add(clickedFileItem); + _selectedFilesPaths.Add(clickedFileItem.FullPath); + } var filePaths = _selectedFiles.Select(f => f.FullPath).ToList(); if (filePaths.Count > 0 && _pendingPointerPressedEventArgs is not null) @@ -389,8 +401,12 @@ private async void SelectAllBtnClick(object? sender, RoutedEventArgs e) if (await box.ShowAsync() != ButtonResult.Yes) return; } - - foreach (var fileItem in _allFiles) _selectedFiles.Add(fileItem); + + foreach (var fileItem in _allFiles) + { + _selectedFiles.Add(fileItem); + _selectedFilesPaths.Add(fileItem.FullPath); + } } private async void ClearSelectedBtnClick(object? sender, RoutedEventArgs e) @@ -403,6 +419,8 @@ private async void ClearSelectedBtnClick(object? sender, RoutedEventArgs e) if (await box.ShowAsync() != ButtonResult.Yes) return; _selectedFiles.Clear(); + _selectedFilesPaths.Clear(); + await File.WriteAllLinesAsync(SelectSightDataFile, []); RefreshFilesInfoText(); ShowFeedback("Cleared all selected files", 3); }