From 8e3e1d681f91e542eee45432942da2c3d37c5f0d Mon Sep 17 00:00:00 2001 From: David Palmer Date: Tue, 30 Jul 2024 19:06:45 -0500 Subject: [PATCH 1/9] Bump version to 0.4.4-alpha. --- Horizon/Horizon.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Horizon/Horizon.csproj b/Horizon/Horizon.csproj index 03f1c2e..69e9b8a 100644 --- a/Horizon/Horizon.csproj +++ b/Horizon/Horizon.csproj @@ -7,7 +7,7 @@ enable latest true - 0.4.3 + 0.4.4 alpha false false From 280a3ff58628a2833eec35708459c2d85cd00d5d Mon Sep 17 00:00:00 2001 From: David Palmer Date: Thu, 1 Aug 2024 04:13:42 -0500 Subject: [PATCH 2/9] Changed Initialize Application to static async task. Added PluginDirectory to App. #28 --- Horizon/App.xaml.cs | 57 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/Horizon/App.xaml.cs b/Horizon/App.xaml.cs index e0503e4..1aa0090 100644 --- a/Horizon/App.xaml.cs +++ b/Horizon/App.xaml.cs @@ -1,4 +1,6 @@ -using Horizon.View.Windows; +using Horizon.API; +using Horizon.ObjectModel; +using Horizon.View.Windows; using Horizon.ViewModel; using ReactiveUI; using ReactiveUI.Fody.Helpers; @@ -52,7 +54,7 @@ public static string UserDirectory ?.GetCustomAttribute() ?.InformationalVersion ?? "unknown-alpha"; - /// + /// [Reactive] public static CommandsViewModel CommandsViewModel { get; } = new(); @@ -76,6 +78,47 @@ public static string AppDataDirectory } } + public static List AvailableTemplates { get; set; } = [new() { Name = "Starbound Mod Project", Description = "A mod project for the game Starbound", Tags = ["Starbound", "Mod"] }]; + private static string? assemblyDirectory; + + public static string AssemblyDirectory + { + get + { + if (assemblyDirectory is null) + { + assemblyDirectory = Assembly.GetExecutingAssembly().Location; + + if (!Directory.Exists(assemblyDirectory)) + { + Directory.CreateDirectory(assemblyDirectory); + } + } + + return assemblyDirectory; + } + } + + private static string? pluginDirectory; + + public static string PluginDirectory + { + get + { + if (pluginDirectory is null) + { + pluginDirectory = Path.Combine(AssemblyDirectory, "Plugins"); + + if (!Directory.Exists(pluginDirectory)) + { + Directory.CreateDirectory(pluginDirectory); + } + } + + return pluginDirectory; + } + } + /// /// The workspace. /// @@ -87,11 +130,11 @@ public static string AppDataDirectory /// Gets all the startup tasks that run while the splash screen is displayed. /// /// - private Queue<(string label, Action action)> GetStartupTasks() + private static Queue<(string label, Action action)> GetStartupTasks() { Queue<(string label, Action action)> tasks = new(); - tasks.Enqueue(("Initializing...", this.InitializeApplication)); + tasks.Enqueue(("Initializing...", async () => await Current.Dispatcher.InvokeAsync(async () => await InitializeApplication()))); tasks.Enqueue(("Opening...", () => Current.Dispatcher.Invoke(() => { @@ -117,7 +160,7 @@ protected override async void OnStartup(StartupEventArgs args) if (splash.ViewModel is not null) { - await splash.ViewModel.StartRunningInBackground(this.GetStartupTasks()); + await splash.ViewModel.StartRunningInBackground(GetStartupTasks()); } splash.Close(); @@ -130,9 +173,9 @@ protected override async void OnStartup(StartupEventArgs args) /// /// Initializes the application in the background. /// - private void InitializeApplication() + private static async Task InitializeApplication() { - // TODO: Place any other startup tasks here, such as loading the most recent project, checking for updates, etc. + await PluginLoader.InitializePlugins(); } /// From 0a5928d80799d27407925dc7a8ff2bf0be25e91d Mon Sep 17 00:00:00 2001 From: David Palmer Date: Thu, 1 Aug 2024 04:13:42 -0500 Subject: [PATCH 3/9] Changed Initialize Application to static async task. Added PluginDirectory to App. InitializePlugins called during application initialization. #28 --- Horizon/App.xaml.cs | 57 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/Horizon/App.xaml.cs b/Horizon/App.xaml.cs index e0503e4..1aa0090 100644 --- a/Horizon/App.xaml.cs +++ b/Horizon/App.xaml.cs @@ -1,4 +1,6 @@ -using Horizon.View.Windows; +using Horizon.API; +using Horizon.ObjectModel; +using Horizon.View.Windows; using Horizon.ViewModel; using ReactiveUI; using ReactiveUI.Fody.Helpers; @@ -52,7 +54,7 @@ public static string UserDirectory ?.GetCustomAttribute() ?.InformationalVersion ?? "unknown-alpha"; - /// + /// [Reactive] public static CommandsViewModel CommandsViewModel { get; } = new(); @@ -76,6 +78,47 @@ public static string AppDataDirectory } } + public static List AvailableTemplates { get; set; } = [new() { Name = "Starbound Mod Project", Description = "A mod project for the game Starbound", Tags = ["Starbound", "Mod"] }]; + private static string? assemblyDirectory; + + public static string AssemblyDirectory + { + get + { + if (assemblyDirectory is null) + { + assemblyDirectory = Assembly.GetExecutingAssembly().Location; + + if (!Directory.Exists(assemblyDirectory)) + { + Directory.CreateDirectory(assemblyDirectory); + } + } + + return assemblyDirectory; + } + } + + private static string? pluginDirectory; + + public static string PluginDirectory + { + get + { + if (pluginDirectory is null) + { + pluginDirectory = Path.Combine(AssemblyDirectory, "Plugins"); + + if (!Directory.Exists(pluginDirectory)) + { + Directory.CreateDirectory(pluginDirectory); + } + } + + return pluginDirectory; + } + } + /// /// The workspace. /// @@ -87,11 +130,11 @@ public static string AppDataDirectory /// Gets all the startup tasks that run while the splash screen is displayed. /// /// - private Queue<(string label, Action action)> GetStartupTasks() + private static Queue<(string label, Action action)> GetStartupTasks() { Queue<(string label, Action action)> tasks = new(); - tasks.Enqueue(("Initializing...", this.InitializeApplication)); + tasks.Enqueue(("Initializing...", async () => await Current.Dispatcher.InvokeAsync(async () => await InitializeApplication()))); tasks.Enqueue(("Opening...", () => Current.Dispatcher.Invoke(() => { @@ -117,7 +160,7 @@ protected override async void OnStartup(StartupEventArgs args) if (splash.ViewModel is not null) { - await splash.ViewModel.StartRunningInBackground(this.GetStartupTasks()); + await splash.ViewModel.StartRunningInBackground(GetStartupTasks()); } splash.Close(); @@ -130,9 +173,9 @@ protected override async void OnStartup(StartupEventArgs args) /// /// Initializes the application in the background. /// - private void InitializeApplication() + private static async Task InitializeApplication() { - // TODO: Place any other startup tasks here, such as loading the most recent project, checking for updates, etc. + await PluginLoader.InitializePlugins(); } /// From 47ad9735333852de7361d5934a3cdd0bee7c73e2 Mon Sep 17 00:00:00 2001 From: David Palmer Date: Sat, 3 Aug 2024 00:26:16 -0500 Subject: [PATCH 4/9] Added CustomPropertyResolver to help remove some POCOObservableForProperty error spam in output. --- Horizon/App.xaml.cs | 3 ++- Horizon/Resolvers/CustomPropertyResolver.cs | 24 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 Horizon/Resolvers/CustomPropertyResolver.cs diff --git a/Horizon/App.xaml.cs b/Horizon/App.xaml.cs index 1aa0090..c41ce41 100644 --- a/Horizon/App.xaml.cs +++ b/Horizon/App.xaml.cs @@ -1,5 +1,5 @@ using Horizon.API; -using Horizon.ObjectModel; +using Horizon.Resolvers; using Horizon.View.Windows; using Horizon.ViewModel; using ReactiveUI; @@ -152,6 +152,7 @@ protected override async void OnStartup(StartupEventArgs args) this.InitializeLogging(); base.OnStartup(args); + Locator.CurrentMutable.Register(() => new CustomPropertyResolver(), typeof(ICreatesObservableForProperty)); Locator.CurrentMutable.RegisterViewsForViewModels(Assembly.GetCallingAssembly()); LoadingSplash splash = new(); diff --git a/Horizon/Resolvers/CustomPropertyResolver.cs b/Horizon/Resolvers/CustomPropertyResolver.cs new file mode 100644 index 0000000..098edb8 --- /dev/null +++ b/Horizon/Resolvers/CustomPropertyResolver.cs @@ -0,0 +1,24 @@ +using ReactiveUI; +using System.Linq.Expressions; +using System.Reactive.Linq; + +namespace Horizon.Resolvers; + +/// +/// Resolves observables for custom properties. +/// +public class CustomPropertyResolver : ICreatesObservableForProperty +{ + /// + public int GetAffinityForObject(Type type, string propertyName, bool beforeChanged = false) + { + return 1; + } + + /// + public IObservable> GetNotificationForProperty(object sender, Expression expression, string propertyName, bool beforeChanged = false, bool suppressWarnings = false) + { + return Observable.Return(new ObservedChange(sender, expression, default!), RxApp.MainThreadScheduler) + .Concat(Observable.Never>()); + } +} \ No newline at end of file From 42a3cd7cb9164542934eb34e0b89c87e63a1cdc4 Mon Sep 17 00:00:00 2001 From: David Palmer Date: Sat, 3 Aug 2024 00:34:06 -0500 Subject: [PATCH 5/9] Added AssetFile. Removed ProjectTemplate and moved its functionality over to ProjectFile. Added DirectoryMonitor and AssetsDirectoryMonitor. Added assetsMonitor to project file. Any Asset files automatically load if they are in the proper location in the project directory. ProjectFile moved to API. Fleshed out Plugin system. Created Horizon.Starbound plugin project. Some things changed around with the new project window to use plugin data instead of hard-coded data. Project now opens after creation. #27 #28 --- Horizon.Starbound/Horizon.Starbound.csproj | 22 ++ Horizon.Starbound/Modules/Item.cs | 7 + Horizon.Starbound/Plugin.cs | 8 + .../Projects/StarboundModProject.cs | 10 + Horizon.sln | 18 ++ Horizon/API/AssetFile.cs | 16 ++ Horizon/API/IPlugin.cs | 6 + Horizon/API/PluginLoadContext.cs | 21 ++ Horizon/API/PluginLoader.cs | 73 +++++++ Horizon/API/ProjectFile.cs | 95 +++++++++ Horizon/App.xaml.cs | 3 +- Horizon/IO/AssetsDirectoryMonitor.cs | 138 ++++++++++++ Horizon/IO/DirectoryMonitor.cs | 200 ++++++++++++++++++ Horizon/ObjectModel/ProjectFile.cs | 42 ---- Horizon/ObjectModel/ProjectTemplate.cs | 16 -- Horizon/View/Windows/NewProjectWindow.xaml | 6 +- Horizon/View/Windows/NewProjectWindow.xaml.cs | 11 +- Horizon/ViewModel/AppViewModel.cs | 27 ++- Horizon/ViewModel/CommandsViewModel.cs | 48 ++++- .../ViewModel/NewProjectWindowViewModel.cs | 39 +++- 20 files changed, 723 insertions(+), 83 deletions(-) create mode 100644 Horizon.Starbound/Horizon.Starbound.csproj create mode 100644 Horizon.Starbound/Modules/Item.cs create mode 100644 Horizon.Starbound/Plugin.cs create mode 100644 Horizon.Starbound/Projects/StarboundModProject.cs create mode 100644 Horizon/API/AssetFile.cs create mode 100644 Horizon/API/IPlugin.cs create mode 100644 Horizon/API/PluginLoadContext.cs create mode 100644 Horizon/API/PluginLoader.cs create mode 100644 Horizon/API/ProjectFile.cs create mode 100644 Horizon/IO/AssetsDirectoryMonitor.cs create mode 100644 Horizon/IO/DirectoryMonitor.cs delete mode 100644 Horizon/ObjectModel/ProjectFile.cs delete mode 100644 Horizon/ObjectModel/ProjectTemplate.cs diff --git a/Horizon.Starbound/Horizon.Starbound.csproj b/Horizon.Starbound/Horizon.Starbound.csproj new file mode 100644 index 0000000..94e842f --- /dev/null +++ b/Horizon.Starbound/Horizon.Starbound.csproj @@ -0,0 +1,22 @@ + + + + net8.0-windows + enable + enable + latest + true + + + + + false + runtime + + + + + + + + diff --git a/Horizon.Starbound/Modules/Item.cs b/Horizon.Starbound/Modules/Item.cs new file mode 100644 index 0000000..0e2468e --- /dev/null +++ b/Horizon.Starbound/Modules/Item.cs @@ -0,0 +1,7 @@ +using Horizon.API; + +namespace Horizon.Starbound.Modules; + +public class Item : AssetFile +{ +} \ No newline at end of file diff --git a/Horizon.Starbound/Plugin.cs b/Horizon.Starbound/Plugin.cs new file mode 100644 index 0000000..861fce3 --- /dev/null +++ b/Horizon.Starbound/Plugin.cs @@ -0,0 +1,8 @@ +using Horizon.API; + +namespace Horizon.Starbound; + +public class Plugin : IPlugin +{ + public string ID { get; set; } +} \ No newline at end of file diff --git a/Horizon.Starbound/Projects/StarboundModProject.cs b/Horizon.Starbound/Projects/StarboundModProject.cs new file mode 100644 index 0000000..4f0a3cf --- /dev/null +++ b/Horizon.Starbound/Projects/StarboundModProject.cs @@ -0,0 +1,10 @@ +using Horizon.ObjectModel; + +namespace Horizon.Starbound.Templates; + +public class StarboundModProject : ProjectFile +{ + public override List Tags { get; } = ["Starbound", "Mod"]; + public override string TemplateName { get; } = "Starbound Mod Project"; + public override string TemplateDescription { get; } = "A mod project for the game Starbound."; +} \ No newline at end of file diff --git a/Horizon.sln b/Horizon.sln index 62013e4..738ed5b 100644 --- a/Horizon.sln +++ b/Horizon.sln @@ -31,6 +31,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ISSUE_TEMPLATE", "ISSUE_TEM EndProject Project("{B7DD6F7E-DEF8-4E67-B5B7-07EF123DB6F0}") = "Horizon.Installer", "Horizon.Installer\Horizon.Installer.wixproj", "{C7407183-3A01-46FC-AC57-1FAEF9318BDC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Horizon.Starbound", "Horizon.Starbound\Horizon.Starbound.csproj", "{234B5F50-6F04-471D-AF3D-5A620B56A260}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -91,6 +93,22 @@ Global {C7407183-3A01-46FC-AC57-1FAEF9318BDC}.Release|x64.Build.0 = Release|x64 {C7407183-3A01-46FC-AC57-1FAEF9318BDC}.Release|x86.ActiveCfg = Release|x86 {C7407183-3A01-46FC-AC57-1FAEF9318BDC}.Release|x86.Build.0 = Release|x86 + {234B5F50-6F04-471D-AF3D-5A620B56A260}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {234B5F50-6F04-471D-AF3D-5A620B56A260}.Debug|Any CPU.Build.0 = Debug|Any CPU + {234B5F50-6F04-471D-AF3D-5A620B56A260}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {234B5F50-6F04-471D-AF3D-5A620B56A260}.Debug|ARM64.Build.0 = Debug|Any CPU + {234B5F50-6F04-471D-AF3D-5A620B56A260}.Debug|x64.ActiveCfg = Debug|Any CPU + {234B5F50-6F04-471D-AF3D-5A620B56A260}.Debug|x64.Build.0 = Debug|Any CPU + {234B5F50-6F04-471D-AF3D-5A620B56A260}.Debug|x86.ActiveCfg = Debug|Any CPU + {234B5F50-6F04-471D-AF3D-5A620B56A260}.Debug|x86.Build.0 = Debug|Any CPU + {234B5F50-6F04-471D-AF3D-5A620B56A260}.Release|Any CPU.ActiveCfg = Release|Any CPU + {234B5F50-6F04-471D-AF3D-5A620B56A260}.Release|Any CPU.Build.0 = Release|Any CPU + {234B5F50-6F04-471D-AF3D-5A620B56A260}.Release|ARM64.ActiveCfg = Release|Any CPU + {234B5F50-6F04-471D-AF3D-5A620B56A260}.Release|ARM64.Build.0 = Release|Any CPU + {234B5F50-6F04-471D-AF3D-5A620B56A260}.Release|x64.ActiveCfg = Release|Any CPU + {234B5F50-6F04-471D-AF3D-5A620B56A260}.Release|x64.Build.0 = Release|Any CPU + {234B5F50-6F04-471D-AF3D-5A620B56A260}.Release|x86.ActiveCfg = Release|Any CPU + {234B5F50-6F04-471D-AF3D-5A620B56A260}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Horizon/API/AssetFile.cs b/Horizon/API/AssetFile.cs new file mode 100644 index 0000000..90c4ebd --- /dev/null +++ b/Horizon/API/AssetFile.cs @@ -0,0 +1,16 @@ +using Horizon.ObjectModel; + +namespace Horizon.API; + +public class AssetFile : UniqueJsonFile +{ + public override Task Load() + { + throw new NotImplementedException(); + } + + public override Task Unload() + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Horizon/API/IPlugin.cs b/Horizon/API/IPlugin.cs new file mode 100644 index 0000000..9f63aba --- /dev/null +++ b/Horizon/API/IPlugin.cs @@ -0,0 +1,6 @@ +namespace Horizon.API; + +public interface IPlugin +{ + public string ID { get; set; } +} \ No newline at end of file diff --git a/Horizon/API/PluginLoadContext.cs b/Horizon/API/PluginLoadContext.cs new file mode 100644 index 0000000..cd3ea81 --- /dev/null +++ b/Horizon/API/PluginLoadContext.cs @@ -0,0 +1,21 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace Horizon.API; + +public class PluginLoadContext(string pluginPath) : AssemblyLoadContext +{ + private readonly AssemblyDependencyResolver resolver = new(pluginPath); + + protected override Assembly? Load(AssemblyName assemblyName) + { + string? assemblyPath = this.resolver.ResolveAssemblyToPath(assemblyName); + return assemblyPath is not null ? this.LoadFromAssemblyPath(assemblyPath) : null; + } + + protected override nint LoadUnmanagedDll(string unmanagedDllName) + { + string? libraryPath = this.resolver.ResolveUnmanagedDllToPath(unmanagedDllName); + return libraryPath is not null ? this.LoadUnmanagedDllFromPath(libraryPath) : IntPtr.Zero; + } +} \ No newline at end of file diff --git a/Horizon/API/PluginLoader.cs b/Horizon/API/PluginLoader.cs new file mode 100644 index 0000000..a3c95be --- /dev/null +++ b/Horizon/API/PluginLoader.cs @@ -0,0 +1,73 @@ +using System.Collections.ObjectModel; +using System.IO; +using System.Reflection; + +namespace Horizon.API; + +internal static class PluginLoader +{ + internal static Task InitializePlugins() + { + List pluginPaths = []; + + foreach (string folder in Directory.GetDirectories(App.PluginDirectory)) + { + string? folderName = new DirectoryInfo(folder).Name; + + if (string.IsNullOrWhiteSpace(folderName)) + { + continue; + } + + if (!File.Exists(Path.Combine(folder, $"{folderName}.dll"))) + { + continue; + } + + pluginPaths.Add(Path.Combine(folder, $"{folderName}.dll")); + } + + List plugins = pluginPaths + .SelectMany(pluginPath => + { + Assembly pluginAssembly = LoadPlugin(pluginPath); + return CreatePlugins(pluginAssembly); + }).ToList(); + + App.ViewModel.Plugins = new ObservableCollection(plugins); + + return Task.CompletedTask; + } + + internal static Assembly LoadPlugin(string pluginPath) + { + PluginLoadContext loadContext = new(pluginPath); + return loadContext.LoadFromAssemblyName(new AssemblyName(Path.GetFileNameWithoutExtension(pluginPath))); + } + + internal static IEnumerable CreatePlugins(Assembly assembly) + { + int count = 0; + + foreach (Type type in assembly.GetTypes()) + { + if (typeof(IPlugin).IsAssignableFrom(type)) + { + IPlugin? result = Activator.CreateInstance(type) as IPlugin; + if (result is not null) + { + count++; + yield return result; + } + } + } + + if (count == 0) + { + string availableTypes = string.Join(",", assembly.GetTypes().Select(t => t.FullName)); + throw new ApplicationException( + $"Can't find any type which implements IPlugin in {assembly} from {assembly.Location}.\n" + + $"Available types: {availableTypes}"); + } + } +} \ No newline at end of file diff --git a/Horizon/API/ProjectFile.cs b/Horizon/API/ProjectFile.cs new file mode 100644 index 0000000..1325c75 --- /dev/null +++ b/Horizon/API/ProjectFile.cs @@ -0,0 +1,95 @@ +using DynamicData; +using Horizon.API; +using Horizon.IO; +using Newtonsoft.Json; +using ReactiveUI; +using ReactiveUI.Fody.Helpers; +using System.IO; +using System.Reactive.Linq; + +namespace Horizon.ObjectModel; + +/// +/// Contains project metadata and serves as a root for related project files in the directory it is contained in. +/// +[JsonObject(MemberSerialization.OptIn)] +public abstract class ProjectFile : JsonFile +{ + private readonly SourceCache assets = new(asset => asset.ID); + + private AssetsDirectoryMonitor assetsMonitor = null!; + + /// + public ProjectFile() : base() + { + // The assets directory is based on the project directory + this.WhenAnyValue(project => project.FileDirectory) + .Select(path => string.IsNullOrWhiteSpace(path) ? string.Empty : path) + .Select(directory => Path.Combine(directory, "Assets")) + .ToPropertyEx(this, project => project.AssetsDirectory); + } + + public abstract string TemplateName { get; } + + public abstract string TemplateDescription { get; } + + public abstract List Tags { get; } + + /// + /// The name of the . + /// + [JsonProperty, Reactive] + public string Name { get; set; } = string.Empty; + + /// + /// The directory containing the project's files. + /// + [ObservableAsProperty] + public string AssetsDirectory { get; } = string.Empty; + + public virtual Task Setup() => Task.CompletedTask; + + /// + public override async Task Load() + { + await this.GetInitialAssets(); + + this.assetsMonitor = new(this, this.assets); + } + + /// + public override async Task Unload() + { + await this.assetsMonitor.Close(); + } + + /// + /// Connects to the Assets collection. + /// + /// An observable change set. + public IObservable> ConnectModules() => this.assets.Connect(); + + /// + /// Loads the files that are initially on disk when the is loaded. + /// + /// An awaitable . + private async Task GetInitialAssets() + { + if (!Directory.Exists(this.AssetsDirectory)) + { + Directory.CreateDirectory(this.AssetsDirectory); + } + + foreach (string assetFile in Directory.GetFiles(this.AssetsDirectory, "*.hasset")) + { + AssetFile? asset = await FromFile(assetFile); + + if (asset is null) + { + continue; + } + + this.assets.AddOrUpdate(asset); + } + } +} \ No newline at end of file diff --git a/Horizon/App.xaml.cs b/Horizon/App.xaml.cs index c41ce41..cfe1efe 100644 --- a/Horizon/App.xaml.cs +++ b/Horizon/App.xaml.cs @@ -78,7 +78,6 @@ public static string AppDataDirectory } } - public static List AvailableTemplates { get; set; } = [new() { Name = "Starbound Mod Project", Description = "A mod project for the game Starbound", Tags = ["Starbound", "Mod"] }]; private static string? assemblyDirectory; public static string AssemblyDirectory @@ -87,7 +86,7 @@ public static string AssemblyDirectory { if (assemblyDirectory is null) { - assemblyDirectory = Assembly.GetExecutingAssembly().Location; + assemblyDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!; if (!Directory.Exists(assemblyDirectory)) { diff --git a/Horizon/IO/AssetsDirectoryMonitor.cs b/Horizon/IO/AssetsDirectoryMonitor.cs new file mode 100644 index 0000000..8f07436 --- /dev/null +++ b/Horizon/IO/AssetsDirectoryMonitor.cs @@ -0,0 +1,138 @@ +using DynamicData; +using Horizon.API; +using Horizon.ObjectModel; +using System.IO; + +namespace Horizon.IO; + +/// +/// Watches the assets directory for file updates. +/// +public sealed class AssetsDirectoryMonitor(ProjectFile project, SourceCache assets) + : DirectoryMonitor(project.AssetsDirectory, "*.hasset") +{ + /// + protected override Task FileDeleted(string fileName, string path, FileSystemEventArgs args) + { + // Check if the asset already exists + AssetFile? asset = assets.Lookup(args.Name ?? string.Empty).Value; + + // Remove it if it did exist + if (asset is not null) + { + assets.Remove(asset); + } + + return Task.CompletedTask; + } + + /// + protected override async Task ExtensionChanged(string? oldExtension, string? newExtension, RenamedEventArgs args) + { + // Check if the asset already exists + AssetFile? asset = assets.Lookup(args.Name ?? string.Empty).Value; + + // If the new extension is not a hasset file then we should remove it if it exists + if (newExtension is not "hasset" && asset is not null) + { + assets.Remove(asset); + } + else if (newExtension is "hasset" && asset is null) + { + // If the new extension is a hasset file then load it from disk and add it to the collection + AssetFile? newAsset = await JsonFile.FromFile(args.FullPath); + if (newAsset is not null) + { + assets.AddOrUpdate(newAsset); + } + } + } + + /// + protected override async Task FileCreated(string fileName, string fullPath, FileSystemEventArgs args) + { + // Check if the asset already exists + AssetFile? asset = assets.Lookup(args.Name ?? string.Empty).Value; + + if (asset is not null) + { + return; + } + + // If it doesn't, load it from disk and add it to the collection + AssetFile? newAsset = await JsonFile.FromFile(args.FullPath); + if (newAsset is not null) + { + assets.AddOrUpdate(newAsset); + } + } + + /// + protected override Task FileModified(string fileName, string path, FileSystemEventArgs args) => Task.CompletedTask; + + /// + protected override async Task FileNameChanged(string? oldName, string? newName, RenamedEventArgs args) + { + // Check if the asset already exists + AssetFile? asset = assets.Lookup(args.OldName!).Value; + + // If it doesn't, load it from disk and add it to the collection + if (asset is null) + { + AssetFile? newAsset = await JsonFile.FromFile(args.FullPath); + if (newAsset is not null) + { + assets.AddOrUpdate(newAsset); + } + } + else + { + // If it does, change its path to update its values + asset.FilePath = args.FullPath; + } + } + + /// + protected override async Task DirectorySyncTick(IEnumerable filePaths, IEnumerable fileNames) + { + int currentPath = 0; + + // Look for files that haven't been loaded in yet + foreach (string fileName in fileNames) + { + // Check if the asset already exists + AssetFile? asset = assets.Lookup(fileName).Value; + + // If it doesn't, load it from disk and add it to the collection + if (asset is null) + { + AssetFile? newAsset = await JsonFile.FromFile(filePaths.ElementAt(currentPath)); + if (newAsset is not null) + { + assets.AddOrUpdate(newAsset); + } + } + + currentPath++; + } + + List filesToRemove = []; + + // Look for loaded files that have been blown away + foreach (AssetFile asset in assets.Items) + { + // If it is not in the directory, we can assume it was blown away or moved somewhere we don't care about + if (!fileNames.Contains(asset.FileName)) + { + // Put it on the chopping block + filesToRemove.Add(asset); + } + } + + // Blow away all the desynced items + foreach (AssetFile asset in filesToRemove) + { + assets.Remove(asset); + } + } +} \ No newline at end of file diff --git a/Horizon/IO/DirectoryMonitor.cs b/Horizon/IO/DirectoryMonitor.cs new file mode 100644 index 0000000..28ac309 --- /dev/null +++ b/Horizon/IO/DirectoryMonitor.cs @@ -0,0 +1,200 @@ +using Nito.AsyncEx.Synchronous; +using System.IO; +using System.Timers; +using Timer = System.Timers.Timer; + +namespace Horizon.IO; + +/// +/// Watches a directory for file changes and acts upon them. +/// +public abstract class DirectoryMonitor +{ + /// + /// The underlying . + /// + private FileSystemWatcher? watcher; + + /// + /// The timer used to perform periodic directory syncs. + /// + private Timer? directorySyncTimer; + + /// + /// Creates a new instance of . + /// + /// The directory to watch. + /// The filter to use on files in the directory. + public DirectoryMonitor(string directory, string filter) + { + this.watcher = new FileSystemWatcher() + { + Path = directory, + IncludeSubdirectories = true, + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName, + Filter = filter + }; + + this.watcher.Created += this.OnFileCreated; + this.watcher.Deleted += this.OnFileDeleted; + this.watcher.Changed += this.OnFileChanged; + this.watcher.Renamed += this.OnFileRenamed; + this.watcher.EnableRaisingEvents = true; + + this.directorySyncTimer = new Timer(TimeSpan.FromSeconds(10)) { AutoReset = true }; + this.directorySyncTimer.Elapsed += this.OnDirectorySyncTick; + } + + /// + /// Closes the and breaks it down. + /// + /// An awaitable . + public Task Close() + { + if (this.watcher is not null) + { + this.watcher.EnableRaisingEvents = false; + this.watcher.Created -= this.OnFileCreated; + this.watcher.Deleted -= this.OnFileDeleted; + this.watcher.Changed -= this.OnFileChanged; + this.watcher.Renamed -= this.OnFileRenamed; + this.watcher.Dispose(); + this.watcher = null; + } + + if (this.directorySyncTimer is not null) + { + this.directorySyncTimer.Stop(); + this.directorySyncTimer.Elapsed -= this.OnDirectorySyncTick; + this.directorySyncTimer.Dispose(); + this.directorySyncTimer = null; + } + + return Task.CompletedTask; + } + + /// + /// Fires when a directory sync occurs. Use this to pick up stragglers that aren't picked up by watcher events. + /// + /// The paths of the files currently in the watched directory. + /// The names of the files currently in the watched directory. + /// An awaitable . + protected abstract Task DirectorySyncTick(IEnumerable filePaths, IEnumerable fileNames); + + /// + /// Fires when a file has been deleted. + /// + /// The name of the file without extension. + /// The full path of the file. + /// The event arguments from the original delete event. + /// An awaitable . + protected abstract Task FileDeleted(string fileName, string path, FileSystemEventArgs args); + + /// + /// Fires when a file has been created. + /// + /// The name of the file without extension. + /// The full path of the file. + /// The event arguments from the original create event. + /// An awaitable . + protected abstract Task FileCreated(string fileName, string fullPath, FileSystemEventArgs args); + + /// + /// Fires when a file's extension has changed. + /// + /// The old extension. + /// The new extension. + /// The event arguments from the original rename event. + /// An awaitable . + protected abstract Task ExtensionChanged(string? oldExtension, string? newExtension, RenamedEventArgs args); + + /// + /// Fires when a file's name has changed. + /// + /// The old name. + /// The new name. + /// The event arguments from the original rename event. + /// An awaitable . + protected abstract Task FileNameChanged(string? oldName, string? newName, RenamedEventArgs args); + + /// + /// Fires when a file has been modified. + /// + /// The name of the file without extension. + /// The full path of the file. + /// The event arguments from the original change event. + /// An awaitable . + protected abstract Task FileModified(string fileName, string path, FileSystemEventArgs args); + + /// + /// Handles a directory sync tick. + /// + /// The sender. + /// The event arguments. + /// + private void OnDirectorySyncTick(object? sender, ElapsedEventArgs args) + { + if (this.watcher is not null) + { + string[] paths = Directory.GetFiles(this.watcher.Path, this.watcher.Filter); + this.DirectorySyncTick(paths, paths.Select(path => Path.GetFileName(path))).WaitAndUnwrapException(); + } + } + + /// + /// Handles a file rename. + /// + /// The sender. + /// The event arguments. + private void OnFileRenamed(object sender, RenamedEventArgs args) + { + string? oldExtension = Path.GetExtension(args.OldName); + string? newExtension = Path.GetExtension(args.Name); + + if (oldExtension != newExtension) + { + this.ExtensionChanged(oldExtension, newExtension, args).WaitAndUnwrapException(); + } + + string? oldFileName = Path.GetFileNameWithoutExtension(args.OldName); + string? newFileName = Path.GetFileNameWithoutExtension(args.Name); + + if (oldFileName != newFileName) + { + this.FileNameChanged(oldFileName, newFileName, args).WaitAndUnwrapException(); + } + } + + /// + /// Handles a file change. + /// + /// The sender. + /// The event arguments. + private void OnFileChanged(object sender, FileSystemEventArgs args) + { + this.FileModified(Path.GetFileNameWithoutExtension(args.Name) ?? string.Empty, args.FullPath, args) + .WaitAndUnwrapException(); + } + + /// + /// Handles a file deletion. + /// + /// The sender. + /// The event arguments. + private void OnFileDeleted(object sender, FileSystemEventArgs args) + { + this.FileDeleted(Path.GetFileNameWithoutExtension(args.Name) ?? string.Empty, args.FullPath, args) + .WaitAndUnwrapException(); + } + + /// + /// Handles a file creation. + /// + /// The sender. + /// The event arguments. + private void OnFileCreated(object sender, FileSystemEventArgs args) + { + this.FileCreated(Path.GetFileNameWithoutExtension(args.Name) ?? string.Empty, args.FullPath, args) + .WaitAndUnwrapException(); + } +} \ No newline at end of file diff --git a/Horizon/ObjectModel/ProjectFile.cs b/Horizon/ObjectModel/ProjectFile.cs deleted file mode 100644 index 78e74b9..0000000 --- a/Horizon/ObjectModel/ProjectFile.cs +++ /dev/null @@ -1,42 +0,0 @@ -using DynamicData; -using Newtonsoft.Json; -using ReactiveUI.Fody.Helpers; -using ReactiveUI; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Horizon.ObjectModel; - -/// -/// Contains project metadata and serves as a root for related project files in the directory it is contained in. -/// -[JsonObject(MemberSerialization.OptIn)] -public sealed class ProjectFile : JsonFile -{ - /// - public ProjectFile() : base() - { - } - - /// - /// The name of the . - /// - [JsonProperty, Reactive] - public string Name { get; set; } = string.Empty; - - /// - public override Task Load() - { - return Task.CompletedTask; - } - - /// - public override Task Unload() - { - return Task.CompletedTask; - } -} \ No newline at end of file diff --git a/Horizon/ObjectModel/ProjectTemplate.cs b/Horizon/ObjectModel/ProjectTemplate.cs deleted file mode 100644 index 38d0de0..0000000 --- a/Horizon/ObjectModel/ProjectTemplate.cs +++ /dev/null @@ -1,16 +0,0 @@ -using ReactiveUI; -using ReactiveUI.Fody.Helpers; - -namespace Horizon.ObjectModel; - -public class ProjectTemplate : ReactiveObject -{ - [Reactive] - public string Name { get; set; } = string.Empty; - - [Reactive] - public string Description { get; set; } = string.Empty; - - [Reactive] - public List Tags { get; set; } = []; -} \ No newline at end of file diff --git a/Horizon/View/Windows/NewProjectWindow.xaml b/Horizon/View/Windows/NewProjectWindow.xaml index d748bf6..3ca91b2 100644 --- a/Horizon/View/Windows/NewProjectWindow.xaml +++ b/Horizon/View/Windows/NewProjectWindow.xaml @@ -18,13 +18,13 @@ - + - - + + diff --git a/Horizon/View/Windows/NewProjectWindow.xaml.cs b/Horizon/View/Windows/NewProjectWindow.xaml.cs index be46eb5..027782d 100644 --- a/Horizon/View/Windows/NewProjectWindow.xaml.cs +++ b/Horizon/View/Windows/NewProjectWindow.xaml.cs @@ -24,12 +24,12 @@ public NewProjectWindow() this.WhenActivated(dispose => { this.Bind(this.ViewModel, - vm => vm.Project.Name, + vm => vm.Project!.Name, view => view.ProjectName.Text) .DisposeWith(dispose); this.Bind(this.ViewModel, - vm => vm.Project.FilePath, + vm => vm.Project!.FilePath, view => view.FilePath.Text) .DisposeWith(dispose); @@ -62,6 +62,11 @@ public NewProjectWindow() }) .DisposeWith(dispose); + this.Bind(this.ViewModel, + vm => vm.SelectedTemplate, + view => view.ProjectTemplatesList.SelectedItem) + .DisposeWith(dispose); + this.CloseButton.Events() .Click .Subscribe(x => @@ -85,7 +90,7 @@ public NewProjectWindow() if (dialog.ShowDialog() == CommonFileDialogResult.Ok) { - this.ViewModel.Project.FilePath = Path.Combine(dialog.FileName, "Project.horizon"); + this.ViewModel.Project!.FilePath = Path.Combine(dialog.FileName, "Project.horizon"); } this.Activate(); diff --git a/Horizon/ViewModel/AppViewModel.cs b/Horizon/ViewModel/AppViewModel.cs index 15d2241..336a7d1 100644 --- a/Horizon/ViewModel/AppViewModel.cs +++ b/Horizon/ViewModel/AppViewModel.cs @@ -1,14 +1,37 @@ -using Horizon.ObjectModel; +using Horizon.API; +using Horizon.ObjectModel; +using Nito.Disposables.Internals; using ReactiveUI; using ReactiveUI.Fody.Helpers; +using System; using System.Collections.ObjectModel; +using System.Reactive; +using System.Reactive.Linq; +using System.Reflection; namespace Horizon.ViewModel; public sealed class AppViewModel : ReactiveObject { + public AppViewModel() + { + this.WhenAnyValue(x => x.Plugins) + .Subscribe(plugins => + { + var templates = plugins.Select(x => Assembly.GetAssembly(x.GetType())) + .WhereNotNull() + .SelectMany(x => x.GetTypes().Where(y => y.IsSubclassOf(typeof(ProjectFile)))) + .Select(x => (ProjectFile?)Activator.CreateInstance(x)) + .WhereNotNull(); + this.AvailableTemplates = new ObservableCollection(templates); + }); + } + + [Reactive] + public ObservableCollection Plugins { get; set; } = []; + [Reactive] - public ObservableCollection AvailableTemplates { get; set; } = [new() { Name = "Starbound Mod Project", Description = "A mod project for the game Starbound", Tags = ["Starbound", "Mod"] }]; + public ObservableCollection AvailableTemplates { get; set; } [Reactive] public ProjectFile? CurrentProject { get; set; } diff --git a/Horizon/ViewModel/CommandsViewModel.cs b/Horizon/ViewModel/CommandsViewModel.cs index 17ea0e2..3f61f49 100644 --- a/Horizon/ViewModel/CommandsViewModel.cs +++ b/Horizon/ViewModel/CommandsViewModel.cs @@ -29,17 +29,21 @@ public CommandsViewModel() public Interaction NewProjectDialogInteraction { get; } = new(); /// - /// handles the "New Project" dialog and creates a new project if the input is accepted. + /// Closes the current . /// - /// An awaitable . - private async Task HandleNewProjectDialog() + public static void CloseCurrentProject() { - ProjectFile? project = await this.NewProjectDialogInteraction.Handle(Unit.Default); + App.ViewModel.CurrentProject = null; + } - if (project is not null) - { - await this.CreateNewProject(project); - } + /// + /// Loads the specified and sets it as the current project. + /// + /// The to load. + public static async Task LoadProject(ProjectFile project) + { + App.ViewModel.CurrentProject = project; + await App.ViewModel.CurrentProject.Load(); } /// @@ -48,7 +52,7 @@ private async Task HandleNewProjectDialog() /// /// A object with initial data such as save path, used as a seed for the new project. /// - private async Task CreateNewProject(ProjectFile project) + private static async Task CreateNewProject(ProjectFile project) { if (!Directory.Exists(project.FileDirectory)) { @@ -57,6 +61,30 @@ private async Task CreateNewProject(ProjectFile project) await project.Save(); - // await this.OpenProject(project); + await OpenProject(project); + } + + /// + /// Opens an existing on disk. + /// + /// A object loaded from disk. + private static async Task OpenProject(ProjectFile project) + { + CloseCurrentProject(); + await LoadProject(project); + } + + /// + /// handles the "New Project" dialog and creates a new project if the input is accepted. + /// + /// An awaitable . + private async Task HandleNewProjectDialog() + { + ProjectFile? project = await this.NewProjectDialogInteraction.Handle(Unit.Default); + + if (project is not null) + { + await CreateNewProject(project); + } } } \ No newline at end of file diff --git a/Horizon/ViewModel/NewProjectWindowViewModel.cs b/Horizon/ViewModel/NewProjectWindowViewModel.cs index 2678de7..19fd5a5 100644 --- a/Horizon/ViewModel/NewProjectWindowViewModel.cs +++ b/Horizon/ViewModel/NewProjectWindowViewModel.cs @@ -17,8 +17,8 @@ public sealed class NewProjectWindowViewModel : ReactiveObject public NewProjectWindowViewModel() { this.WhenAnyValue( - vm => vm.Project.Name, - vm => vm.Project.FileDirectory) + vm => vm.Project!.Name, + vm => vm.Project!.FilePath) .Select(_ => this.validator.Validate(this)) .ToPropertyEx(this, vm => vm.ValidationResult); @@ -46,6 +46,29 @@ public NewProjectWindowViewModel() vm => vm.SelectedTemplate) .Select(x => x is not null) .ToPropertyEx(this, vm => vm.CanPressProjectTemplateNext); + + this.WhenAnyValue( + vm => vm.SelectedTemplate) + .Select(x => + { + if (x is not null) + { + var instance = (ProjectFile?)Activator.CreateInstance(x.GetType()); + if (instance is not null) + { + instance.Name = "New Project"; + instance.FilePath = Path.Combine(App.UserDirectory, "Project.horizon"); + } + + return instance; + } + + return null; + }) + .Subscribe(x => + { + this.Project = x; + }); } [ObservableAsProperty] @@ -64,11 +87,17 @@ public NewProjectWindowViewModel() public bool CanPressProjectTemplateNext { get; } [Reactive] - public ProjectTemplate? SelectedTemplate { get; set; } + public ProjectFile? SelectedTemplate { get; set; } + + [Reactive] + public ProjectFile? Project { get; set; } + + [Reactive] + public string ProjectName { get; set; } = "New Project"; [Reactive] - public ProjectFile Project { get; set; } = new() { Name = "Project Name", FilePath = Path.Combine(App.UserDirectory, "Project.horizon") }; + public string ProjectPath { get; set; } = Path.Combine(App.UserDirectory, "Project.horizon"); [Reactive] - public ObservableCollection AvailableTemplates { get; init; } = []; + public ObservableCollection AvailableTemplates { get; set; } } \ No newline at end of file From aa9d9cc1ba03507095c88d9bbc755f76a3f36e2b Mon Sep 17 00:00:00 2001 From: David Palmer Date: Sat, 3 Aug 2024 02:09:23 -0500 Subject: [PATCH 6/9] Turned GenerateAssemblyInfo back on. --- Horizon.sln | 38 +++++++++++++++++++------------------- Horizon/Horizon.csproj | 2 +- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Horizon.sln b/Horizon.sln index 738ed5b..0e5d63f 100644 --- a/Horizon.sln +++ b/Horizon.sln @@ -5,8 +5,6 @@ VisualStudioVersion = 17.10.35027.167 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Horizon", "Horizon\Horizon.csproj", "{83A33026-94B7-4C90-A7D5-C42A25E7D105}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Horizon.Testing", "Horizon.Testing\Horizon.Testing.csproj", "{23F3058E-5053-4EBC-B701-35B0A444CDE2}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{66E3A57B-1FEA-48C2-B925-6E622922CB67}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig @@ -31,7 +29,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ISSUE_TEMPLATE", "ISSUE_TEM EndProject Project("{B7DD6F7E-DEF8-4E67-B5B7-07EF123DB6F0}") = "Horizon.Installer", "Horizon.Installer\Horizon.Installer.wixproj", "{C7407183-3A01-46FC-AC57-1FAEF9318BDC}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Horizon.Starbound", "Horizon.Starbound\Horizon.Starbound.csproj", "{234B5F50-6F04-471D-AF3D-5A620B56A260}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Horizon.Starbound", "Horizon.Starbound\Horizon.Starbound.csproj", "{234B5F50-6F04-471D-AF3D-5A620B56A260}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Horizon.Testing", "Horizon.Testing\Horizon.Testing.csproj", "{7A543094-A54A-4E5D-A54F-DAFB17F0034F}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -61,22 +61,6 @@ Global {83A33026-94B7-4C90-A7D5-C42A25E7D105}.Release|x64.Build.0 = Release|Any CPU {83A33026-94B7-4C90-A7D5-C42A25E7D105}.Release|x86.ActiveCfg = Release|Any CPU {83A33026-94B7-4C90-A7D5-C42A25E7D105}.Release|x86.Build.0 = Release|Any CPU - {23F3058E-5053-4EBC-B701-35B0A444CDE2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {23F3058E-5053-4EBC-B701-35B0A444CDE2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {23F3058E-5053-4EBC-B701-35B0A444CDE2}.Debug|ARM64.ActiveCfg = Debug|Any CPU - {23F3058E-5053-4EBC-B701-35B0A444CDE2}.Debug|ARM64.Build.0 = Debug|Any CPU - {23F3058E-5053-4EBC-B701-35B0A444CDE2}.Debug|x64.ActiveCfg = Debug|Any CPU - {23F3058E-5053-4EBC-B701-35B0A444CDE2}.Debug|x64.Build.0 = Debug|Any CPU - {23F3058E-5053-4EBC-B701-35B0A444CDE2}.Debug|x86.ActiveCfg = Debug|Any CPU - {23F3058E-5053-4EBC-B701-35B0A444CDE2}.Debug|x86.Build.0 = Debug|Any CPU - {23F3058E-5053-4EBC-B701-35B0A444CDE2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {23F3058E-5053-4EBC-B701-35B0A444CDE2}.Release|Any CPU.Build.0 = Release|Any CPU - {23F3058E-5053-4EBC-B701-35B0A444CDE2}.Release|ARM64.ActiveCfg = Release|Any CPU - {23F3058E-5053-4EBC-B701-35B0A444CDE2}.Release|ARM64.Build.0 = Release|Any CPU - {23F3058E-5053-4EBC-B701-35B0A444CDE2}.Release|x64.ActiveCfg = Release|Any CPU - {23F3058E-5053-4EBC-B701-35B0A444CDE2}.Release|x64.Build.0 = Release|Any CPU - {23F3058E-5053-4EBC-B701-35B0A444CDE2}.Release|x86.ActiveCfg = Release|Any CPU - {23F3058E-5053-4EBC-B701-35B0A444CDE2}.Release|x86.Build.0 = Release|Any CPU {C7407183-3A01-46FC-AC57-1FAEF9318BDC}.Debug|Any CPU.ActiveCfg = Debug|x64 {C7407183-3A01-46FC-AC57-1FAEF9318BDC}.Debug|Any CPU.Build.0 = Debug|x64 {C7407183-3A01-46FC-AC57-1FAEF9318BDC}.Debug|ARM64.ActiveCfg = Debug|ARM64 @@ -109,6 +93,22 @@ Global {234B5F50-6F04-471D-AF3D-5A620B56A260}.Release|x64.Build.0 = Release|Any CPU {234B5F50-6F04-471D-AF3D-5A620B56A260}.Release|x86.ActiveCfg = Release|Any CPU {234B5F50-6F04-471D-AF3D-5A620B56A260}.Release|x86.Build.0 = Release|Any CPU + {7A543094-A54A-4E5D-A54F-DAFB17F0034F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7A543094-A54A-4E5D-A54F-DAFB17F0034F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7A543094-A54A-4E5D-A54F-DAFB17F0034F}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {7A543094-A54A-4E5D-A54F-DAFB17F0034F}.Debug|ARM64.Build.0 = Debug|Any CPU + {7A543094-A54A-4E5D-A54F-DAFB17F0034F}.Debug|x64.ActiveCfg = Debug|Any CPU + {7A543094-A54A-4E5D-A54F-DAFB17F0034F}.Debug|x64.Build.0 = Debug|Any CPU + {7A543094-A54A-4E5D-A54F-DAFB17F0034F}.Debug|x86.ActiveCfg = Debug|Any CPU + {7A543094-A54A-4E5D-A54F-DAFB17F0034F}.Debug|x86.Build.0 = Debug|Any CPU + {7A543094-A54A-4E5D-A54F-DAFB17F0034F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7A543094-A54A-4E5D-A54F-DAFB17F0034F}.Release|Any CPU.Build.0 = Release|Any CPU + {7A543094-A54A-4E5D-A54F-DAFB17F0034F}.Release|ARM64.ActiveCfg = Release|Any CPU + {7A543094-A54A-4E5D-A54F-DAFB17F0034F}.Release|ARM64.Build.0 = Release|Any CPU + {7A543094-A54A-4E5D-A54F-DAFB17F0034F}.Release|x64.ActiveCfg = Release|Any CPU + {7A543094-A54A-4E5D-A54F-DAFB17F0034F}.Release|x64.Build.0 = Release|Any CPU + {7A543094-A54A-4E5D-A54F-DAFB17F0034F}.Release|x86.ActiveCfg = Release|Any CPU + {7A543094-A54A-4E5D-A54F-DAFB17F0034F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Horizon/Horizon.csproj b/Horizon/Horizon.csproj index 69e9b8a..53ef1ec 100644 --- a/Horizon/Horizon.csproj +++ b/Horizon/Horizon.csproj @@ -7,10 +7,10 @@ enable latest true + 0.4.4 alpha false - false false Horizon.ico true From 2f5f71ef2b51e8b2b38075051ce0e6824cc8a9c3 Mon Sep 17 00:00:00 2001 From: David Palmer Date: Sat, 3 Aug 2024 02:10:51 -0500 Subject: [PATCH 7/9] Fixed a log not using the variables defined in its template. Changed Json serialization to include type definitions in serialization. #27 #28 --- Horizon/ObjectModel/JsonFile.cs | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/Horizon/ObjectModel/JsonFile.cs b/Horizon/ObjectModel/JsonFile.cs index 7d2a7a7..cd618e6 100644 --- a/Horizon/ObjectModel/JsonFile.cs +++ b/Horizon/ObjectModel/JsonFile.cs @@ -80,6 +80,33 @@ public JsonFile() return file; } + /// + /// Loads the from disk and casts it to the abstract type. + /// + /// The abstract base type of the . + /// The path of the , including the name and extension. + /// + /// An awaitable that returns a or . + /// + /// + public static async Task FromAbstractFile(string path) where TJsonFile : JsonFile + { + if (!File.Exists(path)) + { + Log.Error("Attempted to load json file of type {JsonFileType} at path {JsonFilePath}, but it does not exist.", typeof(TJsonFile), path); + return default; + } + + string json = await File.ReadAllTextAsync(path); + + TJsonFile? file = JsonConvert.DeserializeObject(json, new JsonSerializerSettings() { TypeNameHandling = TypeNameHandling.All }) as TJsonFile + ?? throw new InvalidOperationException("The loaded file could not be deserialized."); + + file.FilePath = path; + + return file; + } + /// /// Loads the and performs initialization operations. /// @@ -97,8 +124,8 @@ public JsonFile() /// public async Task Save() { - Log.Debug("Saving file of type {JsonFileType} at path {JsonFilePath}."); - string json = JsonConvert.SerializeObject(this, Formatting.Indented); + Log.Debug("Saving file of type {JsonFileType} at path {JsonFilePath}.", this.GetType(), this.FilePath); + string json = JsonConvert.SerializeObject(this, Formatting.Indented, new JsonSerializerSettings() { TypeNameHandling = TypeNameHandling.All }); await File.WriteAllTextAsync(this.FilePath, json); } From e0f37e1cd43a8bd1460b621ff69068fb2892a8b7 Mon Sep 17 00:00:00 2001 From: David Palmer Date: Sat, 3 Aug 2024 02:11:32 -0500 Subject: [PATCH 8/9] Added Open Project to Main Menu and implemented dialog interaction for opening an existing project. #27 #28 --- Horizon/View/Controls/MainMenu.xaml | 10 ++++++++ Horizon/View/Controls/MainMenu.xaml.cs | 6 +++++ Horizon/View/Windows/Workspace.xaml.cs | 32 ++++++++++++++++++++++++++ Horizon/ViewModel/CommandsViewModel.cs | 25 ++++++++++++++++++++ 4 files changed, 73 insertions(+) diff --git a/Horizon/View/Controls/MainMenu.xaml b/Horizon/View/Controls/MainMenu.xaml index 6736059..8842007 100644 --- a/Horizon/View/Controls/MainMenu.xaml +++ b/Horizon/View/Controls/MainMenu.xaml @@ -17,6 +17,16 @@ + + + + + + + + + + diff --git a/Horizon/View/Controls/MainMenu.xaml.cs b/Horizon/View/Controls/MainMenu.xaml.cs index 65e862a..d37b7cf 100644 --- a/Horizon/View/Controls/MainMenu.xaml.cs +++ b/Horizon/View/Controls/MainMenu.xaml.cs @@ -26,6 +26,12 @@ public MainMenu() .Select(x => new Unit()) .InvokeCommand(App.CommandsViewModel, x => x.NewProjectDialog) .DisposeWith(dispose); + + this.OpenProjectMenuItem.Events() + .Click + .Select(x => new Unit()) + .InvokeCommand(App.CommandsViewModel, x => x.OpenProjectDialog) + .DisposeWith(dispose); }); } } \ No newline at end of file diff --git a/Horizon/View/Windows/Workspace.xaml.cs b/Horizon/View/Windows/Workspace.xaml.cs index 1357455..a5bf6b1 100644 --- a/Horizon/View/Windows/Workspace.xaml.cs +++ b/Horizon/View/Windows/Workspace.xaml.cs @@ -1,5 +1,7 @@ using AvalonDock; using Horizon.Converters; +using Horizon.ObjectModel; +using Microsoft.WindowsAPICodePack.Dialogs; using ReactiveMarbles.ObservableEvents; using ReactiveUI; using System.Reactive; @@ -124,5 +126,35 @@ private void Interactions(CompositeDisposable dispose) }, RxApp.MainThreadScheduler); }) .DisposeWith(dispose); + + App.CommandsViewModel + .OpenProjectDialogInteraction + .RegisterHandler(interaction => + { + CommonOpenFileDialog fileDialog = new() { EnsureFileExists = true, Title = "Select a project file...", Multiselect = false }; + fileDialog.Filters.Add(new CommonFileDialogFilter("Project Files", "Project.horizon")); + + return Observable.StartAsync(async () => + { + if (fileDialog.ShowDialog() == CommonFileDialogResult.Ok) + { + ProjectFile? project = await JsonFile.FromAbstractFile(fileDialog.FileName); + + if (project is not null) + { + interaction.SetOutput(project); + } + else + { + interaction.SetOutput(null); + } + } + else + { + interaction.SetOutput(null); + } + }, RxApp.MainThreadScheduler); + }) + .DisposeWith(dispose); } } \ No newline at end of file diff --git a/Horizon/ViewModel/CommandsViewModel.cs b/Horizon/ViewModel/CommandsViewModel.cs index 3f61f49..2e3b871 100644 --- a/Horizon/ViewModel/CommandsViewModel.cs +++ b/Horizon/ViewModel/CommandsViewModel.cs @@ -16,6 +16,7 @@ public sealed class CommandsViewModel : ReactiveObject public CommandsViewModel() { this.NewProjectDialog = ReactiveCommand.CreateFromTask(this.HandleNewProjectDialog); + this.OpenProjectDialog = ReactiveCommand.CreateFromTask(this.HandleOpenProjectDialog); } /// @@ -23,6 +24,16 @@ public CommandsViewModel() /// public ReactiveCommand NewProjectDialog { get; set; } + /// + /// Command that opens an "Open Project" file picker and waits for the interaction to complete. + /// + public ReactiveCommand OpenProjectDialog { get; set; } + + /// + /// Interaction that handles the "Open Project" dialog without blocking the UI. + /// + public Interaction OpenProjectDialogInteraction { get; } = new(); + /// /// Interaction that handles the "New Project" dialog without blocking the UI. /// @@ -74,6 +85,20 @@ private static async Task OpenProject(ProjectFile project) await LoadProject(project); } + /// + /// Handles the "Open Project" dialog and opens the selected project if the input is accepted. + /// + /// An awaitable . + private async Task HandleOpenProjectDialog() + { + ProjectFile? project = await this.OpenProjectDialogInteraction.Handle(Unit.Default); + + if (project is not null) + { + await OpenProject(project); + } + } + /// /// handles the "New Project" dialog and creates a new project if the input is accepted. /// From 71ff769635f0ef170c0e4684e379700549fb9931 Mon Sep 17 00:00:00 2001 From: David Palmer Date: Sat, 3 Aug 2024 12:49:11 -0500 Subject: [PATCH 9/9] Added plugins functionality to installer. WixUI_InstallDir changed to WixUI_FeatureTree to allow installing individual components. Fixed an issure where log files from dev would be copied into the installer. Bumped version to 0.4.4 in installer. #28 --- Horizon.Installer/Package.wxs | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/Horizon.Installer/Package.wxs b/Horizon.Installer/Package.wxs index f421c9e..22f1ac6 100644 --- a/Horizon.Installer/Package.wxs +++ b/Horizon.Installer/Package.wxs @@ -1,10 +1,10 @@  - + - + @@ -19,8 +19,22 @@ - + + + + + + + + + + + + + + + @@ -30,15 +44,24 @@ - - + + + + + + + + + + + \ No newline at end of file