요구를 잘못 읽었었다. Git 저장소 이야기로 보고 .git 찾아 올라가기를 만들었는데, 실제로는 폴더 비교 탭의 폴더 고르는 창 이야기였다. 상위 폴더를 골라 놓고 하위까지 한꺼번에 비교하고 싶은데 그 상위 폴더를 고를 방법이 없었다. 원인은 폴더 고르기를 파일 열기 창으로 돌려쓰고 있던 것이다. 이 방식은 지금 서 있는 폴더를 돌려주기 때문에, 목록에 보이는 폴더를 고르려면 반드시 그 안으로 들어가야 한다. 그래서 늘 가장 깊은 폴더만 골라졌다. - 윈도우 폴더 선택 창을 셸 COM(IFileDialog + FOS_PICKFOLDERS)으로 직접 부른다. 목록에서 폴더를 한 번 눌러 고를 수 있다. COM 이 안 되면 전에 쓰던 방식으로 물러선다. - SetDefaultFolder 가 아니라 SetFolder 를 쓴다. 전자는 윈도우가 기억한 폴더를 우선해서 지금 경로 칸에 적힌 폴더에서 열리지 않는다. - D-4(비교 창 여러 개)는 사용자 판단으로 취소. 번호는 그대로 둔다. 확인: 위로 한 번 올라가 목록의 right 폴더를 한 번 눌러 골랐고 경로 칸에 그대로 들어왔다. 그 상태로 비교하니 doc / src / src·core / src·ui 트리가 나오고 src·core·weld.cs 까지 펼쳐진다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
207 lines
9.0 KiB
C#
207 lines
9.0 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Runtime.InteropServices;
|
|
using System.Windows;
|
|
using System.Windows.Interop;
|
|
|
|
namespace MergeHub.Services
|
|
{
|
|
/// <summary>
|
|
/// 폴더 하나를 고르는 창. 네 자리(폴더 비교 왼쪽·오른쪽, Git 저장소 경로, 변경분 추출 대상)가 같은 것을 쓴다.
|
|
///
|
|
/// 윈도우가 원래 가지고 있는 <b>폴더 선택 창</b>(탐색기에서 "폴더 선택"이라고 뜨는 그것)을 부른다.
|
|
/// 목록에 보이는 폴더를 <b>눌러서 그대로 고를 수 있어야</b> 하기 때문이다 -
|
|
/// 상위 폴더를 골라 놓고 하위까지 한꺼번에 비교하는 것이 폴더 비교의 기본 사용법인데,
|
|
/// 그 폴더 안으로 들어가야만 골라지면 늘 가장 깊은 폴더만 고르게 된다.
|
|
///
|
|
/// net48 WPF·WinForms 에는 이 창을 부르는 것이 없어 셸 COM 을 직접 쓴다.
|
|
/// (WinForms 의 FolderBrowserDialog 는 주소 칸도 없는 옛날 트리 창이고,
|
|
/// 파일 열기 창을 폴더 고르기로 돌려쓰는 방법은 위에 적은 "들어가야만 골라지는" 문제가 있다.)
|
|
/// COM 이 안 되는 환경이 있을 수 있으므로 그때는 파일 열기 창으로 물러선다.
|
|
/// </summary>
|
|
public static class FolderPicker
|
|
{
|
|
/// <summary>고른 폴더의 전체 경로. 취소하면 null.</summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
// ===================== 물러설 자리 =====================
|
|
|
|
/// <summary>
|
|
/// 셸 창을 못 쓸 때. 파일 열기 창을 폴더 고르기로 돌려쓴다 -
|
|
/// 파일 이름 칸을 그대로 두고 열기를 누르면 지금 보고 있는 폴더가 선택된다.
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <summary>사용자가 취소했을 때의 HRESULT(ERROR_CANCELLED).</summary>
|
|
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
|
|
{
|
|
}
|
|
|
|
/// <remarks>
|
|
/// 메서드 <b>순서가 곧 규약</b>이다(IModalWindow 를 이어받는다). 하나라도 빼거나 순서를 바꾸면
|
|
/// 엉뚱한 함수가 불려 프로그램이 죽는다. 쓰지 않는 것도 자리를 채워 둔다.
|
|
/// </remarks>
|
|
[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);
|
|
}
|
|
}
|
|
} |