fix: open folder pickers at current paths

This commit is contained in:
2026-07-27 01:25:09 +09:00
parent ee82f0be42
commit 8cad072cf0
3 changed files with 273 additions and 24 deletions

View File

@@ -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<string?> PickOperatorFolderAsync(
string? initialDirectory,
string title)
{
ArgumentException.ThrowIfNullOrWhiteSpace(title);
var canonicalInitialDirectory = initialDirectory is null
? null
: Path.GetFullPath(initialDirectory);
var completion = new TaskCompletionSource<string?>(
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,

View File

@@ -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

View File

@@ -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(