Multi-Platform Package Manager for Stable Diffusion
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

634 lines
21 KiB

using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.IO;
using System.Linq;
using System.Reactive.Linq;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Controls.Notifications;
using Avalonia.Input;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DynamicData;
using DynamicData.Binding;
using FluentAvalonia.UI.Controls;
using StabilityMatrix.Avalonia.Services;
using StabilityMatrix.Avalonia.ViewModels.Base;
using StabilityMatrix.Core.Attributes;
using StabilityMatrix.Core.Exceptions;
using StabilityMatrix.Core.Extensions;
using StabilityMatrix.Core.Helper;
using StabilityMatrix.Core.Models;
using StabilityMatrix.Core.Models.FileInterfaces;
using StabilityMatrix.Core.Models.Progress;
using StabilityMatrix.Core.Processes;
using StabilityMatrix.Core.Services;
namespace StabilityMatrix.Avalonia.ViewModels.CheckpointManager;
[ManagedService]
[Transient]
public partial class CheckpointFolder : ViewModelBase
{
private readonly ISettingsManager settingsManager;
private readonly IDownloadService downloadService;
private readonly ModelFinder modelFinder;
private readonly INotificationService notificationService;
public SourceCache<CheckpointFolder, string> SubFoldersCache { get; } =
new(x => x.DirectoryPath);
private readonly SourceCache<CheckpointFile, string> checkpointFilesCache =
new(x => x.FilePath);
// ReSharper disable once FieldCanBeMadeReadOnly.Local
private bool useCategoryVisibility;
/// <summary>
/// Absolute path to the folder.
/// </summary>
public string DirectoryPath { get; init; } = string.Empty;
/// <summary>
/// Custom title for UI.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FolderType))]
[NotifyPropertyChangedFor(nameof(TitleWithFilesCount))]
private string title = string.Empty;
[ObservableProperty]
private SharedFolderType folderType;
/// <summary>
/// True if the category is enabled for the manager page.
/// </summary>
[ObservableProperty]
private bool isCategoryEnabled = true;
/// <summary>
/// True if currently expanded in the UI.
/// </summary>
[ObservableProperty]
private bool isExpanded = true;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsDragBlurEnabled))]
private bool isCurrentDragTarget;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsDragBlurEnabled))]
private bool isImportInProgress;
[ObservableProperty]
private string searchFilter = string.Empty;
public bool IsDragBlurEnabled => IsCurrentDragTarget || IsImportInProgress;
public string TitleWithFilesCount =>
CheckpointFiles.Any() || SubFolders.Any(f => f.CheckpointFiles.Any())
? $"{Title} ({CheckpointFiles.Count + SubFolders.Sum(folder => folder.CheckpointFiles.Count)})"
: Title;
public ProgressViewModel Progress { get; } = new();
public CheckpointFolder? ParentFolder { get; init; }
public IObservableCollection<CheckpointFolder> SubFolders { get; } =
new ObservableCollectionExtended<CheckpointFolder>();
public IObservableCollection<CheckpointFile> CheckpointFiles { get; } =
new ObservableCollectionExtended<CheckpointFile>();
public IObservableCollection<CheckpointFile> DisplayedCheckpointFiles { get; set; } =
new ObservableCollectionExtended<CheckpointFile>();
public CheckpointFolder(
ISettingsManager settingsManager,
IDownloadService downloadService,
ModelFinder modelFinder,
INotificationService notificationService,
bool useCategoryVisibility = true
)
{
this.settingsManager = settingsManager;
this.downloadService = downloadService;
this.modelFinder = modelFinder;
this.notificationService = notificationService;
this.useCategoryVisibility = useCategoryVisibility;
checkpointFilesCache
.Connect()
.DeferUntilLoaded()
.SubscribeMany(
file =>
Observable
.FromEventPattern<EventArgs>(file, nameof(ParentListRemoveRequested))
.Subscribe(_ => checkpointFilesCache.Remove(file))
)
.Bind(CheckpointFiles)
.Sort(
SortExpressionComparer<CheckpointFile>
.Descending(f => f.IsConnectedModel)
.ThenByAscending(
f => f.IsConnectedModel ? f.ConnectedModel!.ModelName : f.FileName
)
)
.Filter(
f =>
f.FileName.Contains(SearchFilter, StringComparison.OrdinalIgnoreCase)
|| f.Title.Contains(SearchFilter, StringComparison.OrdinalIgnoreCase)
)
.Bind(DisplayedCheckpointFiles)
.Subscribe();
SubFoldersCache
.Connect()
.DeferUntilLoaded()
.SortBy(x => x.Title)
.Bind(SubFolders)
.Subscribe();
CheckpointFiles.CollectionChanged += OnCheckpointFilesChanged;
// DisplayedCheckpointFiles = CheckpointFiles;
}
/// <summary>
/// When title is set, set the category enabled state from settings.
/// </summary>
// ReSharper disable once UnusedParameterInPartialMethod
partial void OnTitleChanged(string value)
{
if (!useCategoryVisibility)
return;
// Update folder type
var result = Enum.TryParse(Title, out SharedFolderType type);
FolderType = result ? type : new SharedFolderType();
IsCategoryEnabled = settingsManager.IsSharedFolderCategoryVisible(FolderType);
}
partial void OnSearchFilterChanged(string value)
{
foreach (var subFolder in SubFolders)
{
subFolder.SearchFilter = value;
}
checkpointFilesCache.Refresh();
}
/// <summary>
/// When toggling the category enabled state, save it to settings.
/// </summary>
partial void OnIsCategoryEnabledChanged(bool value)
{
if (!useCategoryVisibility)
return;
if (value != settingsManager.IsSharedFolderCategoryVisible(FolderType))
{
settingsManager.SetSharedFolderCategoryVisible(FolderType, value);
}
}
private void OnCheckpointFilesChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
OnPropertyChanged(nameof(TitleWithFilesCount));
}
public async Task OnDrop(DragEventArgs e)
{
IsImportInProgress = true;
IsCurrentDragTarget = false;
try
{
// {System.Linq.Enumerable.WhereEnumerableIterator<Avalonia.Platform.Storage.IStorageItem>}
if (e.Data.Get(DataFormats.Files) is IEnumerable<IStorageItem> files)
{
var paths = files.Select(f => f.Path.LocalPath).ToArray();
await ImportFilesAsync(paths, settingsManager.Settings.IsImportAsConnected);
}
else if (e.Data.Get("Context") is CheckpointFile file)
{
await MoveBetweenFolders(file);
}
}
catch (Exception)
{
// If no exception this will be handled by DelayedClearProgress()
IsImportInProgress = false;
}
}
[RelayCommand]
private async Task ShowInExplorer(string path)
{
await ProcessRunner.OpenFolderBrowser(path);
}
[RelayCommand]
private async Task Delete()
{
var directory = new DirectoryPath(DirectoryPath);
if (!directory.Exists)
{
RemoveFromParentList();
return;
}
var dialog = DialogHelper.CreateTaskDialog(
"Are you sure you want to delete this folder?",
directory
);
dialog.ShowProgressBar = false;
dialog.Buttons = new List<TaskDialogButton>
{
TaskDialogButton.YesButton,
TaskDialogButton.NoButton
};
dialog.Closing += async (sender, e) =>
{
// We only want to use the deferral on the 'Yes' Button
if ((TaskDialogStandardResult)e.Result == TaskDialogStandardResult.Yes)
{
var deferral = e.GetDeferral();
sender.ShowProgressBar = true;
sender.SetProgressBarState(0, TaskDialogProgressState.Indeterminate);
await using (new MinimumDelay(200, 300))
{
await directory.DeleteAsync(true);
}
RemoveFromParentList();
deferral.Complete();
}
};
dialog.XamlRoot = App.VisualRoot;
await dialog.ShowAsync(true);
}
[RelayCommand]
private async Task CreateSubFolder()
{
Dispatcher.UIThread.VerifyAccess();
var textBox = new TextBox();
var dialog = new ContentDialog
{
Title = "Folder name",
Content = textBox,
DefaultButton = ContentDialogButton.Primary,
PrimaryButtonText = "Create",
CloseButtonText = "Cancel",
IsPrimaryButtonEnabled = true,
};
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
var targetName = textBox.Text;
if (string.IsNullOrWhiteSpace(targetName))
return;
var subFolderPath = Path.Combine(DirectoryPath, targetName);
Directory.CreateDirectory(subFolderPath);
SubFolders.Add(
new CheckpointFolder(
settingsManager,
downloadService,
modelFinder,
notificationService,
useCategoryVisibility: false
)
{
Title = Path.GetFileName(subFolderPath),
DirectoryPath = subFolderPath,
FolderType = FolderType,
ParentFolder = this,
IsExpanded = false,
}
);
}
}
public async Task MoveBetweenFolders(CheckpointFile sourceFile)
{
var delay = 1.5f;
try
{
Progress.Value = 0;
var sourcePath = new FilePath(sourceFile.FilePath);
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(sourcePath);
var sourceCmInfoPath = Path.Combine(
sourcePath.Directory,
$"{fileNameWithoutExt}.cm-info.json"
);
var sourcePreviewPath = Path.Combine(
sourcePath.Directory,
$"{fileNameWithoutExt}.preview.jpeg"
);
var destinationFilePath = Path.Combine(DirectoryPath, sourcePath.Name);
var destinationCmInfoPath = Path.Combine(
DirectoryPath,
$"{fileNameWithoutExt}.cm-info.json"
);
var destinationPreviewPath = Path.Combine(
DirectoryPath,
$"{fileNameWithoutExt}.preview.jpeg"
);
// Move files
if (File.Exists(sourcePath))
{
Progress.Text = $"Moving {sourcePath.Name}...";
await FileTransfers.MoveFileAsync(sourcePath, destinationFilePath);
}
Progress.Value = 33;
Progress.Text = $"Moving {sourcePath.Name} metadata...";
if (File.Exists(sourceCmInfoPath))
{
await FileTransfers.MoveFileAsync(sourceCmInfoPath, destinationCmInfoPath);
}
Progress.Value = 66;
if (File.Exists(sourcePreviewPath))
{
await FileTransfers.MoveFileAsync(sourcePreviewPath, destinationPreviewPath);
}
Progress.Value = 100;
Progress.Text = $"Moved {sourcePath.Name} to {Title}";
sourceFile.OnMoved();
BackgroundIndex();
delay = 0.5f;
}
catch (FileTransferExistsException)
{
Progress.Value = 0;
Progress.Text = "Failed to move file: destination file exists";
}
finally
{
DelayedClearProgress(TimeSpan.FromSeconds(delay));
}
}
/// <summary>
/// Imports files to the folder. Reports progress to instance properties.
/// </summary>
public async Task ImportFilesAsync(
IEnumerable<string> files,
bool convertToConnected = false,
bool copyFiles = true
)
{
try
{
Progress.Value = 0;
var copyPaths = files.ToDictionary(
k => k,
v => Path.Combine(DirectoryPath, Path.GetFileName(v))
);
var progress = new Progress<ProgressReport>(report =>
{
Progress.IsIndeterminate = false;
Progress.Value = report.Percentage;
// For multiple files, add count
Progress.Text =
copyPaths.Count > 1
? $"Importing {report.Title} ({report.Message})"
: $"Importing {report.Title}";
});
if (copyFiles)
{
await FileTransfers.CopyFiles(copyPaths, progress);
}
// Hash files and convert them to connected model if found
if (convertToConnected)
{
var modelFilesCount = copyPaths.Count;
var modelFiles = copyPaths.Values.Select(path => new FilePath(path));
// Holds tasks for model queries after hash
var modelQueryTasks = new List<Task<bool>>();
foreach (var (i, modelFile) in modelFiles.Enumerate())
{
var hashProgress = new Progress<ProgressReport>(report =>
{
Progress.IsIndeterminate = report.IsIndeterminate;
Progress.Value = report.Percentage;
Progress.Text =
modelFilesCount > 1
? $"Computing metadata for {modelFile.Name} ({i}/{modelFilesCount})"
: $"Computing metadata for {modelFile.Name}";
});
var hashBlake3 = await FileHash.GetBlake3Async(modelFile, hashProgress);
// Start a task to query the model in background
var queryTask = Task.Run(async () =>
{
var result = await modelFinder.LocalFindModel(hashBlake3);
result ??= await modelFinder.RemoteFindModel(hashBlake3);
if (result is null)
return false; // Not found
var (model, version, file) = result.Value;
// Save connected model info json
var modelFileName = Path.GetFileNameWithoutExtension(modelFile.Info.Name);
var modelInfo = new ConnectedModelInfo(
model,
version,
file,
DateTimeOffset.UtcNow
);
await modelInfo.SaveJsonToDirectory(DirectoryPath, modelFileName);
// If available, save thumbnail
var image = version.Images?.FirstOrDefault();
if (image != null)
{
var imageExt = Path.GetExtension(image.Url).TrimStart('.');
if (imageExt is "jpg" or "jpeg" or "png")
{
var imageDownloadPath = Path.GetFullPath(
Path.Combine(
DirectoryPath,
$"{modelFileName}.preview.{imageExt}"
)
);
await downloadService.DownloadToFileAsync(
image.Url,
imageDownloadPath
);
}
}
return true;
});
modelQueryTasks.Add(queryTask);
}
// Set progress to indeterminate
Progress.IsIndeterminate = true;
Progress.Text = "Checking connected model information";
// Wait for all model queries to finish
var modelQueryResults = await Task.WhenAll(modelQueryTasks);
var successCount = modelQueryResults.Count(r => r);
var totalCount = modelQueryResults.Length;
var failCount = totalCount - successCount;
BackgroundIndex();
Progress.Value = 100;
Progress.Text = successCount switch
{
0 when failCount > 0 => "Import complete. No connected data found.",
> 0 when failCount > 0
=> $"Import complete. Found connected data for {successCount} of {totalCount} models.",
1 when failCount == 0 => "Import complete. Found connected data for 1 model.",
_ => $"Import complete. Found connected data for all {totalCount} models."
};
}
else
{
Progress.Text = "Import complete";
Progress.Value = 100;
BackgroundIndex();
}
}
finally
{
DelayedClearProgress(TimeSpan.FromSeconds(1.5));
}
}
public async Task FindConnectedMetadata()
{
IsImportInProgress = true;
var files = CheckpointFiles
.Where(f => !f.IsConnectedModel)
.Select(f => f.FilePath)
.ToList();
if (files.Any())
{
await ImportFilesAsync(files, true, false);
}
else
{
notificationService.Show(
"Cannot Find Connected Metadata",
"All files in this folder are already connected.",
NotificationType.Warning
);
}
}
/// <summary>
/// Clears progress after a delay.
/// </summary>
private void DelayedClearProgress(TimeSpan delay)
{
Task.Delay(delay)
.ContinueWith(_ =>
{
IsImportInProgress = false;
Progress.Value = 0;
Progress.IsIndeterminate = false;
Progress.Text = string.Empty;
});
}
/// <summary>
/// Gets checkpoint files from folder index
/// </summary>
private IEnumerable<CheckpointFile> GetCheckpointFiles()
{
if (!Directory.Exists(DirectoryPath))
{
return Enumerable.Empty<CheckpointFile>();
}
return CheckpointFile.FromDirectoryIndex(DirectoryPath);
}
/// <summary>
/// Indexes the folder for checkpoint files and refreshes the CheckPointFiles collection.
/// </summary>
public void BackgroundIndex()
{
Dispatcher.UIThread.Post(Index, DispatcherPriority.Background);
}
/// <summary>
/// Indexes the folder for checkpoint files and refreshes the CheckPointFiles collection.
/// </summary>
public void Index()
{
// Get subfolders
foreach (var folder in Directory.GetDirectories(DirectoryPath))
{
// Get from cache or create new
if (SubFoldersCache.Lookup(folder) is { HasValue: true } result)
{
result.Value.BackgroundIndex();
}
else
{
// Create subfolder
var subFolder = new CheckpointFolder(
settingsManager,
downloadService,
modelFinder,
notificationService,
useCategoryVisibility: false
)
{
Title = Path.GetFileName(folder),
DirectoryPath = folder,
FolderType = FolderType, // Inherit our folder type
ParentFolder = this,
IsExpanded = false, // Subfolders are collapsed by default
};
subFolder.BackgroundIndex();
SubFoldersCache.AddOrUpdate(subFolder);
}
}
// Index files
Dispatcher.UIThread.Post(
() =>
{
var files = GetCheckpointFiles();
checkpointFilesCache.EditDiff(files, CheckpointFile.FilePathComparer);
},
DispatcherPriority.Background
);
}
}