From 8cad072cf0f04d554f519c2da1984187faa082f8 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Mon, 27 Jul 2026 01:25:09 +0900 Subject: [PATCH] fix: open folder pickers at current paths --- .../MainWindow.BackgroundPicker.cs | 130 +++++++++++++++++- .../MainWindow.Settings.cs | 96 +++++++++++-- ...gacyOperatorSettingsNativeContractTests.cs | 71 +++++++++- 3 files changed, 273 insertions(+), 24 deletions(-) diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.BackgroundPicker.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.BackgroundPicker.cs index 4efbc4c..d3bcd79 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.BackgroundPicker.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.BackgroundPicker.cs @@ -17,7 +17,7 @@ public sealed partial class MainWindow try { completion.TrySetResult( - LegacyBackgroundFileDialog.Show( + LegacyFileDialog.ShowBackgroundFile( WindowNative.GetWindowHandle(this), initialDirectory)); } @@ -40,14 +40,54 @@ public sealed partial class MainWindow return completion.Task; } - private static class LegacyBackgroundFileDialog + private Task PickOperatorFolderAsync( + string? initialDirectory, + string title) + { + ArgumentException.ThrowIfNullOrWhiteSpace(title); + var canonicalInitialDirectory = initialDirectory is null + ? null + : Path.GetFullPath(initialDirectory); + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + void ShowPicker() + { + try + { + completion.TrySetResult( + LegacyFileDialog.ShowFolder( + WindowNative.GetWindowHandle(this), + canonicalInitialDirectory, + title)); + } + catch (Exception exception) + { + completion.TrySetException(exception); + } + } + + if (DispatcherQueue.HasThreadAccess) + { + ShowPicker(); + } + else if (!DispatcherQueue.TryEnqueue(ShowPicker)) + { + completion.TrySetException( + new InvalidOperationException("The folder picker could not reach the UI thread.")); + } + + return completion.Task; + } + + private static class LegacyFileDialog { private const int CancelledHResult = unchecked((int)0x800704C7); private const uint FileSystemPathDisplayName = 0x80058000; private static readonly Guid FileOpenDialogClassId = new("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7"); - public static string? Show(nint owner, string initialDirectory) + public static string? ShowBackgroundFile(nint owner, string initialDirectory) { if (owner == nint.Zero || !Directory.Exists(initialDirectory)) { @@ -127,6 +167,89 @@ public sealed partial class MainWindow } } + public static string? ShowFolder( + nint owner, + string? initialDirectory, + string title) + { + if (owner == nint.Zero) + { + throw new InvalidOperationException( + "The folder picker requires an owner window."); + } + + if (initialDirectory is not null && + !Directory.Exists(initialDirectory)) + { + throw new DirectoryNotFoundException( + "The initial folder-picker directory is unavailable."); + } + + IFileDialog? dialog = null; + IShellItem? initialFolder = null; + IShellItem? result = null; + try + { + var dialogType = Type.GetTypeFromCLSID( + FileOpenDialogClassId, + throwOnError: true) ?? + throw new InvalidOperationException( + "The Windows folder dialog is unavailable."); + dialog = (IFileDialog)(Activator.CreateInstance(dialogType) ?? + throw new InvalidOperationException( + "The Windows folder dialog could not be created.")); + dialog.GetOptions(out var existingOptions); + dialog.SetOptions( + existingOptions | + FileOpenOptions.NoChangeDirectory | + FileOpenOptions.PickFolders | + FileOpenOptions.ForceFileSystem | + FileOpenOptions.PathMustExist | + FileOpenOptions.NoDereferenceLinks | + FileOpenOptions.DoNotAddToRecent); + dialog.SetTitle(title); + dialog.SetOkButtonLabel("폴더 선택"); + + if (initialDirectory is not null) + { + var shellItemId = typeof(IShellItem).GUID; + var createResult = SHCreateItemFromParsingName( + initialDirectory, + nint.Zero, + ref shellItemId, + out initialFolder); + Marshal.ThrowExceptionForHR(createResult); + dialog.SetFolder(initialFolder); + dialog.SetDefaultFolder(initialFolder); + } + + var showResult = dialog.Show(owner); + if (showResult == CancelledHResult) + { + return null; + } + + Marshal.ThrowExceptionForHR(showResult); + dialog.GetResult(out result); + result.GetDisplayName(FileSystemPathDisplayName, out var pathPointer); + try + { + return Marshal.PtrToStringUni(pathPointer) ?? + throw new IOException("The selected folder path is unavailable."); + } + finally + { + Marshal.FreeCoTaskMem(pathPointer); + } + } + finally + { + ReleaseComObject(result); + ReleaseComObject(initialFolder); + ReleaseComObject(dialog); + } + } + private static void ReleaseComObject(object? value) { if (value is not null && Marshal.IsComObject(value)) @@ -139,6 +262,7 @@ public sealed partial class MainWindow private enum FileOpenOptions : uint { NoChangeDirectory = 0x00000008, + PickFolders = 0x00000020, ForceFileSystem = 0x00000040, PathMustExist = 0x00000800, FileMustExist = 0x00001000, diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Settings.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Settings.cs index e38da90..9dfb21e 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Settings.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Settings.cs @@ -2,8 +2,6 @@ using MBN_STOCK_WEBVIEW.Infrastructure; using MBN_STOCK_WEBVIEW.LegacyApplication; using MBN_STOCK_WEBVIEW.LegacyBridge; using MMoneyCoderSharp.Data; -using Windows.Storage.Pickers; -using WinRT.Interop; namespace MBN_STOCK_WEBVIEW.LegacyParityApp; @@ -85,18 +83,11 @@ public sealed partial class MainWindow cancellationToken.ThrowIfCancellationRequested(); try { - var picker = new FolderPicker - { - SuggestedStartLocation = PickerLocationId.ComputerFolder, - ViewMode = PickerViewMode.List - }; - picker.FileTypeFilter.Add("*"); - var owner = _windowHandle != 0 - ? _windowHandle - : WindowNative.GetWindowHandle(this); - InitializeWithWindow.Initialize(picker, owner); - var selected = await picker.PickSingleFolderAsync(); - if (selected is null) + var initialDirectory = ResolveOperatorFolderPickerStartDirectory(kind); + var selectedPath = await PickOperatorFolderAsync( + initialDirectory, + ResolveOperatorFolderPickerTitle(kind)); + if (selectedPath is null) { return; } @@ -105,7 +96,7 @@ public sealed partial class MainWindow var validationKind = ToSettingsFolderKind(kind); if (!LegacyOperatorFolderValidator.TryValidate( validationKind, - selected.Path, + selectedPath, out var validated, out var warningMessage) || validated is null) @@ -156,6 +147,81 @@ public sealed partial class MainWindow } } + private string? ResolveOperatorFolderPickerStartDirectory( + LegacyOperatorFolderKind kind) + { + var preferredPath = kind switch + { + LegacyOperatorFolderKind.Design => + _operatorSettings.SceneDirectory ?? + Path.Combine(AppContext.BaseDirectory, "Cuts"), + LegacyOperatorFolderKind.Resource => + _operatorSettings.ResourceDirectory ?? + Path.Combine(AppContext.BaseDirectory, "Res"), + LegacyOperatorFolderKind.Background => + _operatorSettings.BackgroundDirectory ?? + ResolveAutomaticBackgroundDirectory(), + _ => throw new ArgumentOutOfRangeException(nameof(kind)) + }; + + return FindNearestExistingDirectory(preferredPath) ?? + FindNearestExistingDirectory(AppContext.BaseDirectory); + } + + private string ResolveAutomaticBackgroundDirectory() + { + var sceneDirectory = _operatorSettings.SceneDirectory ?? + Path.Combine(AppContext.BaseDirectory, "Cuts"); + var canonicalSceneDirectory = Path.TrimEndingDirectorySeparator( + Path.GetFullPath(sceneDirectory)); + var sceneParent = Path.GetDirectoryName(canonicalSceneDirectory); + return Path.Combine( + sceneParent ?? AppContext.BaseDirectory, + "배경"); + } + + private static string? FindNearestExistingDirectory(string? path) + { + if (string.IsNullOrWhiteSpace(path) || + !Path.IsPathFullyQualified(path)) + { + return null; + } + + try + { + for (var candidate = new DirectoryInfo(Path.GetFullPath(path)); + candidate is not null; + candidate = candidate.Parent) + { + if (candidate.Exists) + { + return candidate.FullName; + } + } + } + catch (Exception exception) when ( + exception is ArgumentException or + IOException or + NotSupportedException or + System.Security.SecurityException or + UnauthorizedAccessException) + { + return null; + } + + return null; + } + + private static string ResolveOperatorFolderPickerTitle( + LegacyOperatorFolderKind kind) => kind switch + { + LegacyOperatorFolderKind.Design => "폴더 선택 - 디자인", + LegacyOperatorFolderKind.Resource => "폴더 선택 - 설정", + LegacyOperatorFolderKind.Background => "폴더 선택 - 배경", + _ => throw new ArgumentOutOfRangeException(nameof(kind)) + }; + private void ResetOperatorFolder(LegacyOperatorFolderKind kind) { var next = kind switch diff --git a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyOperatorSettingsNativeContractTests.cs b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyOperatorSettingsNativeContractTests.cs index 8adb427..2fb05f4 100644 --- a/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyOperatorSettingsNativeContractTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.LegacyWeb.Tests/LegacyOperatorSettingsNativeContractTests.cs @@ -11,26 +11,85 @@ public sealed class LegacyOperatorSettingsNativeContractTests Path.Combine(AppRoot, "MainWindow.xaml.cs")); private static readonly string Settings = File.ReadAllText( Path.Combine(AppRoot, "MainWindow.Settings.cs")); + private static readonly string FolderPicker = File.ReadAllText( + Path.Combine(AppRoot, "MainWindow.BackgroundPicker.cs")); private static readonly string Playout = File.ReadAllText( Path.Combine(AppRoot, "MainWindow.Playout.cs")); [Fact] public void FolderPathsComeOnlyFromTheWindowOwnedNativePicker() { - Assert.Contains("var picker = new FolderPicker", Settings, StringComparison.Ordinal); - Assert.Contains("picker.FileTypeFilter.Add(\"*\")", Settings, + Assert.Contains( + "var initialDirectory = ResolveOperatorFolderPickerStartDirectory(kind)", + Settings, StringComparison.Ordinal); - Assert.Contains("InitializeWithWindow.Initialize(picker, owner);", Settings, + Assert.Contains("await PickOperatorFolderAsync(", Settings, StringComparison.Ordinal); - Assert.Contains("await picker.PickSingleFolderAsync()", Settings, + Assert.Contains("LegacyFileDialog.ShowFolder(", FolderPicker, StringComparison.Ordinal); - Assert.Contains("selected.Path", Settings, StringComparison.Ordinal); + Assert.Contains("WindowNative.GetWindowHandle(this)", FolderPicker, + StringComparison.Ordinal); + Assert.Contains("FileOpenOptions.PickFolders", FolderPicker, + StringComparison.Ordinal); + Assert.Contains("FileOpenOptions.ForceFileSystem", FolderPicker, + StringComparison.Ordinal); + Assert.Contains("FileOpenOptions.PathMustExist", FolderPicker, + StringComparison.Ordinal); + Assert.Contains("FileOpenOptions.NoDereferenceLinks", FolderPicker, + StringComparison.Ordinal); + Assert.Contains("dialog.SetFolder(initialFolder);", FolderPicker, + StringComparison.Ordinal); + Assert.Contains("dialog.SetDefaultFolder(initialFolder);", FolderPicker, + StringComparison.Ordinal); + Assert.Contains("selectedPath", Settings, StringComparison.Ordinal); Assert.Contains("LegacyOperatorFolderValidator.TryValidate(", Settings, StringComparison.Ordinal); + Assert.DoesNotContain("PickerLocationId.ComputerFolder", Settings, + StringComparison.Ordinal); Assert.DoesNotContain("intent.Path", Settings, StringComparison.Ordinal); Assert.DoesNotContain("choose.Path", Settings, StringComparison.Ordinal); } + [Fact] + public void FolderPickerStartsAtTheLatestSavedPathOrItsNearestExistingParent() + { + var resolver = Slice( + Settings, + "private string? ResolveOperatorFolderPickerStartDirectory(", + "private static string ResolveOperatorFolderPickerTitle("); + + Assert.Contains("_operatorSettings.SceneDirectory", resolver, + StringComparison.Ordinal); + Assert.Contains("_operatorSettings.ResourceDirectory", resolver, + StringComparison.Ordinal); + Assert.Contains("_operatorSettings.BackgroundDirectory", resolver, + StringComparison.Ordinal); + Assert.DoesNotContain("_appliedOperatorSettings", resolver, + StringComparison.Ordinal); + Assert.Contains("Path.Combine(AppContext.BaseDirectory, \"Cuts\")", resolver, + StringComparison.Ordinal); + Assert.Contains("Path.Combine(AppContext.BaseDirectory, \"Res\")", resolver, + StringComparison.Ordinal); + Assert.Contains("ResolveAutomaticBackgroundDirectory()", resolver, + StringComparison.Ordinal); + Assert.Contains("FindNearestExistingDirectory(preferredPath)", resolver, + StringComparison.Ordinal); + Assert.Contains("candidate = candidate.Parent", resolver, + StringComparison.Ordinal); + Assert.Contains("FindNearestExistingDirectory(AppContext.BaseDirectory)", resolver, + StringComparison.Ordinal); + } + + [Fact] + public void FolderPickerTitleAndButtonRemainCompatibleWithTheLocalStateSmoke() + { + Assert.Contains("\"폴더 선택 - 디자인\"", Settings, StringComparison.Ordinal); + Assert.Contains("\"폴더 선택 - 설정\"", Settings, StringComparison.Ordinal); + Assert.Contains("\"폴더 선택 - 배경\"", Settings, StringComparison.Ordinal); + Assert.Contains("dialog.SetOkButtonLabel(\"폴더 선택\")", FolderPicker, + StringComparison.Ordinal); + } + [Fact] public void SelectedFoldersAreLoadedAtStartupAndAppliedOnlyAtCompositionBoundaries() { @@ -162,7 +221,7 @@ public sealed class LegacyOperatorSettingsNativeContractTests { var cancelledPicker = Slice( Settings, - "if (selected is null)", + "if (selectedPath is null)", "cancellationToken.ThrowIfCancellationRequested();"); Assert.DoesNotContain(