diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index 0610f7f6fb..e90b3c703f 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -415,6 +415,9 @@ namespace osu.Game.Beatmaps public Task?> ImportAsUpdate(ProgressNotification notification, ImportTask importTask, BeatmapSetInfo original) => beatmapImporter.ImportAsUpdate(notification, importTask, original); + public Task> BeginExternalEditing(BeatmapSetInfo model) => + beatmapImporter.BeginExternalEditing(model); + public Task Export(BeatmapSetInfo beatmap) => beatmapExporter.ExportAsync(beatmap.ToLive(Realm)); public Task ExportLegacy(BeatmapSetInfo beatmap) => legacyBeatmapExporter.ExportAsync(beatmap.ToLive(Realm)); diff --git a/osu.Game/Database/ExternalEditOperation.cs b/osu.Game/Database/ExternalEditOperation.cs new file mode 100644 index 0000000000..ab74cba7d5 --- /dev/null +++ b/osu.Game/Database/ExternalEditOperation.cs @@ -0,0 +1,48 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.IO; +using System.Threading.Tasks; +using osu.Game.Overlays.Notifications; + +namespace osu.Game.Database +{ + public class ExternalEditOperation where TModel : class, IHasGuidPrimaryKey + { + public readonly string MountedPath; + + private readonly IModelImporter importer; + private readonly TModel original; + + private bool isMounted; + + public ExternalEditOperation(IModelImporter importer, TModel original, string path) + { + this.importer = importer; + this.original = original; + + MountedPath = path; + + isMounted = true; + } + + public async Task?> Finish() + { + if (!Directory.Exists(MountedPath) || !isMounted) + return null; + + Live? imported = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(MountedPath), original) + .ConfigureAwait(false); + + try + { + Directory.Delete(MountedPath, true); + } + catch { } + + isMounted = false; + + return imported; + } + } +} diff --git a/osu.Game/Database/IModelImporter.cs b/osu.Game/Database/IModelImporter.cs index dcbbad0d35..c2e5517f2a 100644 --- a/osu.Game/Database/IModelImporter.cs +++ b/osu.Game/Database/IModelImporter.cs @@ -34,6 +34,12 @@ namespace osu.Game.Database /// The imported model. Task?> ImportAsUpdate(ProgressNotification notification, ImportTask task, TModel original); + /// + /// Mount all files for a to a temporary directory to allow for external editing. + /// + /// The to mount. + public Task> BeginExternalEditing(TModel model); + /// /// A user displayable name for the model type associated with this manager. /// diff --git a/osu.Game/Database/RealmArchiveModelImporter.cs b/osu.Game/Database/RealmArchiveModelImporter.cs index 0014e246dc..38df2ac1dc 100644 --- a/osu.Game/Database/RealmArchiveModelImporter.cs +++ b/osu.Game/Database/RealmArchiveModelImporter.cs @@ -179,6 +179,30 @@ namespace osu.Game.Database public virtual Task?> ImportAsUpdate(ProgressNotification notification, ImportTask task, TModel original) => throw new NotImplementedException(); + public async Task> BeginExternalEditing(TModel model) + { + string mountedPath = Path.Join(Path.GetTempPath(), model.Hash); + + if (Directory.Exists(mountedPath)) + Directory.Delete(mountedPath, true); + + Directory.CreateDirectory(mountedPath); + + foreach (var realmFile in model.Files) + { + string sourcePath = Files.Storage.GetFullPath(realmFile.File.GetStoragePath()); + string destinationPath = Path.Join(mountedPath, realmFile.Filename); + + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + + using (var inStream = Files.Storage.GetStream(sourcePath)) + using (var outStream = File.Create(destinationPath)) + await inStream.CopyToAsync(outStream).ConfigureAwait(false); + } + + return new ExternalEditOperation(this, model, mountedPath); + } + /// /// Import one from the filesystem and delete the file on success. /// Note that this bypasses the UI flow and should only be used for special cases or testing. diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index df4735b5e6..e3601fe91e 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -15,10 +15,10 @@ using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Database; using osu.Game.IO.Archives; +using osu.Game.Online.API; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; using osu.Game.Rulesets.Scoring; -using osu.Game.Online.API; using osu.Game.Scoring.Legacy; namespace osu.Game.Scoring @@ -214,6 +214,7 @@ namespace osu.Game.Scoring } public Task?> ImportAsUpdate(ProgressNotification notification, ImportTask task, ScoreInfo original) => scoreImporter.ImportAsUpdate(notification, task, original); + public Task> BeginExternalEditing(ScoreInfo model) => scoreImporter.BeginExternalEditing(model); public Live? Import(ScoreInfo item, ArchiveReader? archive = null, ImportParameters parameters = default, CancellationToken cancellationToken = default) => scoreImporter.ImportModel(item, archive, parameters, cancellationToken); diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 27d0392b1e..ff8cf3997e 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Threading.Tasks; using JetBrains.Annotations; @@ -13,6 +14,7 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; @@ -22,6 +24,7 @@ using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Framework.Logging; +using osu.Framework.Platform; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Framework.Threading; @@ -820,6 +823,9 @@ namespace osu.Game.Screens.Edit resetTrack(); + fileMountOperation?.Dispose(); + fileMountOperation = null; + refetchBeatmap(); return base.OnExiting(e); @@ -1095,6 +1101,11 @@ namespace osu.Game.Screens.Edit lastSavedHash = changeHandler?.CurrentStateHash; } + private EditorMenuItem mountFilesItem; + + [CanBeNull] + private Task> fileMountOperation; + private IEnumerable createFileMenuItems() { yield return createDifficultyCreationMenu(); @@ -1112,12 +1123,44 @@ namespace osu.Game.Screens.Edit var export = createExportMenu(); saveRelatedMenuItems.AddRange(export.Items); yield return export; + + yield return mountFilesItem = new EditorMenuItem("Mount files", MenuItemType.Standard, mountFiles); } yield return new OsuMenuItemSpacer(); yield return new EditorMenuItem(CommonStrings.Exit, MenuItemType.Standard, this.Exit); } + [Resolved] + private GameHost gameHost { get; set; } + + private void mountFiles() + { + if (fileMountOperation == null) + { + Save(); + + fileMountOperation = beatmapManager.BeginExternalEditing(editorBeatmap.BeatmapInfo.BeatmapSet!); + mountFilesItem.Text.Value = "Dismount files"; + + fileMountOperation.ContinueWith(t => + { + var operation = t.GetResultSafely(); + + // Ensure the trailing separator is present in order to show the folder contents. + gameHost.OpenFileExternally(operation.MountedPath.TrimDirectorySeparator() + Path.DirectorySeparatorChar); + }); + } + else + { + fileMountOperation.GetResultSafely().Finish().ContinueWith(t => Schedule(() => + { + fileMountOperation = null; + SwitchToDifficulty(t.GetResultSafely().Value.Detach().Beatmaps.First()); + })); + } + } + private EditorMenuItem createExportMenu() { var exportItems = new List diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index 59c2a8bca0..4f816d88d2 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -312,6 +312,8 @@ namespace osu.Game.Skinning public Task> ImportAsUpdate(ProgressNotification notification, ImportTask task, SkinInfo original) => skinImporter.ImportAsUpdate(notification, task, original); + public Task> BeginExternalEditing(SkinInfo model) => skinImporter.BeginExternalEditing(model); + public Task> Import(ImportTask task, ImportParameters parameters = default, CancellationToken cancellationToken = default) => skinImporter.Import(task, parameters, cancellationToken);