using System; using System.IO; using System.Runtime.InteropServices; using System.Windows; using System.Windows.Interop; namespace MergeHub.Services { /// /// 폴더 하나를 고르는 창. 네 자리(폴더 비교 왼쪽·오른쪽, Git 저장소 경로, 변경분 추출 대상)가 같은 것을 쓴다. /// /// 윈도우가 원래 가지고 있는 폴더 선택 창(탐색기에서 "폴더 선택"이라고 뜨는 그것)을 부른다. /// 목록에 보이는 폴더를 눌러서 그대로 고를 수 있어야 하기 때문이다 - /// 상위 폴더를 골라 놓고 하위까지 한꺼번에 비교하는 것이 폴더 비교의 기본 사용법인데, /// 그 폴더 안으로 들어가야만 골라지면 늘 가장 깊은 폴더만 고르게 된다. /// /// net48 WPF·WinForms 에는 이 창을 부르는 것이 없어 셸 COM 을 직접 쓴다. /// (WinForms 의 FolderBrowserDialog 는 주소 칸도 없는 옛날 트리 창이고, /// 파일 열기 창을 폴더 고르기로 돌려쓰는 방법은 위에 적은 "들어가야만 골라지는" 문제가 있다.) /// COM 이 안 되는 환경이 있을 수 있으므로 그때는 파일 열기 창으로 물러선다. /// public static class FolderPicker { /// 고른 폴더의 전체 경로. 취소하면 null. public static string? Pick(string title, string startFolder) { var start = PathUtil.FullFolderPath(startFolder); try { return PickWithShell(title, start); } catch (Exception) { // 셸 폴더 선택 창을 못 열었다. 고르기 자체를 못 하게 되는 것보다는 낫다. return PickWithFileDialog(title, start); } } // ===================== 윈도우 폴더 선택 창 ===================== private static string? PickWithShell(string title, string start) { var dialog = (IFileDialog)new FileOpenDialogRcw(); dialog.GetOptions(out var options); dialog.SetOptions(options | FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST); dialog.SetTitle(title); dialog.SetOkButtonLabel("이 폴더 선택"); if (start.Length > 0 && TryCreateItem(start, out var startItem)) { // SetDefaultFolder 가 아니라 SetFolder 를 쓴다. SetDefaultFolder 는 윈도우가 // 기억해 둔 "이 프로그램이 마지막으로 본 폴더"가 있으면 그쪽을 쓰기 때문에, // 엉뚱한 곳(지난번에 Git 저장소를 고르러 들어갔던 깊은 폴더 같은)에서 열린다. // 지금 칸에 적힌 폴더에서 여는 것이 맞다. dialog.SetFolder(startItem!); } var hr = dialog.Show(OwnerHandle()); if (hr == HRESULT_CANCELLED) return null; if (hr != 0) Marshal.ThrowExceptionForHR(hr); dialog.GetResult(out var picked); if (picked == null) return null; picked.GetDisplayName(SIGDN_FILESYSPATH, out var buffer); try { var path = Marshal.PtrToStringUni(buffer); return string.IsNullOrEmpty(path) ? null : path; } finally { Marshal.FreeCoTaskMem(buffer); } } private static bool TryCreateItem(string path, out IShellItem? item) { var iid = typeof(IShellItem).GUID; item = SHCreateItemFromParsingName(path, IntPtr.Zero, ref iid, out var created) == 0 ? created : null; return item != null; } private static IntPtr OwnerHandle() { var window = Application.Current?.MainWindow; if (window == null) return IntPtr.Zero; try { return new WindowInteropHelper(window).Handle; } catch (InvalidOperationException) { return IntPtr.Zero; } } // ===================== 물러설 자리 ===================== /// /// 셸 창을 못 쓸 때. 파일 열기 창을 폴더 고르기로 돌려쓴다 - /// 파일 이름 칸을 그대로 두고 열기를 누르면 지금 보고 있는 폴더가 선택된다. /// private static string? PickWithFileDialog(string title, string start) { var dialog = new Microsoft.Win32.OpenFileDialog { Title = title + " (폴더 안에서 아무 파일이나 고르거나, 파일 이름 칸을 그대로 두고 열기)", CheckFileExists = false, CheckPathExists = true, FileName = "이 폴더 선택", Filter = "폴더 (*.*)|*.*" }; // 시작 폴더는 반드시 PathUtil 을 거친 값이어야 한다. 슬래시가 섞인 경로를 그대로 넘기면 // ShowDialog() 안쪽에서 ArgumentException 이 나고 프로그램이 죽는다. PathUtil.cs 참고. if (start.Length > 0) dialog.InitialDirectory = start; bool ok; try { ok = dialog.ShowDialog() == true; } catch (Exception) { dialog.InitialDirectory = string.Empty; ok = dialog.ShowDialog() == true; } if (!ok) return null; var picked = Path.GetDirectoryName(dialog.FileName); return string.IsNullOrEmpty(picked) ? null : picked; } // ===================== 셸 COM 선언 ===================== private const uint FOS_PICKFOLDERS = 0x00000020; private const uint FOS_FORCEFILESYSTEM = 0x00000040; private const uint FOS_PATHMUSTEXIST = 0x00000800; private const uint SIGDN_FILESYSPATH = 0x80058000; /// 사용자가 취소했을 때의 HRESULT(ERROR_CANCELLED). private const int HRESULT_CANCELLED = unchecked((int)0x800704C7); [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = false)] private static extern int SHCreateItemFromParsingName( [MarshalAs(UnmanagedType.LPWStr)] string path, IntPtr bindContext, ref Guid riid, [MarshalAs(UnmanagedType.Interface)] out IShellItem item); [ComImport, Guid("DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7")] private class FileOpenDialogRcw { } /// /// 메서드 순서가 곧 규약이다(IModalWindow 를 이어받는다). 하나라도 빼거나 순서를 바꾸면 /// 엉뚱한 함수가 불려 프로그램이 죽는다. 쓰지 않는 것도 자리를 채워 둔다. /// [ComImport, Guid("42f85136-db7e-439c-85f1-e4075d135fc8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] private interface IFileDialog { // IModalWindow [PreserveSig] int Show(IntPtr parent); // IFileDialog void SetFileTypes(uint fileTypes, IntPtr filterSpec); void SetFileTypeIndex(uint fileType); void GetFileTypeIndex(out uint fileType); void Advise(IntPtr events, out uint cookie); void Unadvise(uint cookie); void SetOptions(uint options); void GetOptions(out uint options); void SetDefaultFolder(IShellItem folder); void SetFolder(IShellItem folder); void GetFolder(out IShellItem folder); void GetCurrentSelection(out IShellItem item); void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string name); void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string name); void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string title); void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string text); void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string label); void GetResult(out IShellItem item); void AddPlace(IShellItem place, int order); void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string extension); void Close([MarshalAs(UnmanagedType.Error)] int result); void SetClientGuid(ref Guid client); void ClearClientData(); void SetFilter(IntPtr filter); } [ComImport, Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] private interface IShellItem { void BindToHandler(IntPtr bindContext, ref Guid handler, ref Guid riid, out IntPtr result); void GetParent(out IShellItem parent); void GetDisplayName(uint kind, out IntPtr name); void GetAttributes(uint mask, out uint attributes); void Compare(IShellItem other, uint hint, out int order); } } }